Merge 9f7452a6a1 into sapling-pr-archive-bolinfest

This commit is contained in:
Michael Bolin
2026-06-26 17:32:33 -07:00
committed by GitHub
289 changed files with 17366 additions and 1116 deletions
+9 -7
View File
@@ -107,8 +107,9 @@ common:ci-bazel --config=ci
common:ci-bazel --build_metadata=TAG_workflow=bazel
# Keep code-mode integration cases out of ordinary Bazel legs. The
# Windows-cross config below re-enables them after generating its Windows V8
# snapshot on the Windows runner.
common:ci-bazel --test_env=CODEX_BAZEL_TEST_SKIP_FILTERS=suite::code_mode::
# snapshot on the Windows runner. Also skip the ConPTY Ctrl-C integration test
# until Windows Bazel CI can reliably interrupt foreground processes.
common:ci-bazel --test_env=CODEX_BAZEL_TEST_SKIP_FILTERS=suite::code_mode::,tests::windows_tests::conpty_ctrl_c_interrupts_powershell_foreground_child
# Shared config for Bazel-backed Rust linting.
build:clippy --aspects=@rules_rust//rust:defs.bzl%rust_clippy_aspect
@@ -191,11 +192,12 @@ common:ci-windows-cross --strategy=TestRunner=local
common:ci-windows-cross --strategy=V8Mksnapshot=local
common:ci-windows-cross --local_test_jobs=4
common:ci-windows-cross --test_env=RUST_TEST_THREADS=1
# Native Windows CI still covers the PowerShell tests. The cross-built gnullvm
# binaries currently hang in PowerShell AST parser tests when those binaries are
# run on the Windows runner. Keep V8-backed code-mode tests enabled except for
# the hidden dynamic-tool callback test, which currently times out on Windows.
common:ci-windows-cross --test_env=CODEX_BAZEL_TEST_SKIP_FILTERS=powershell,suite::code_mode::code_mode_can_call_hidden_dynamic_tools
# Native Windows CI still covers the PowerShell parser-process tests. The
# cross-built gnullvm binaries currently hang in those tests when run on the
# Windows runner. Keep V8-backed code-mode tests enabled except for the hidden
# dynamic-tool callback test, which currently times out on Windows. This config
# replaces the base skip list, so repeat the exact ConPTY Ctrl-C test exclusion.
common:ci-windows-cross --test_env=CODEX_BAZEL_TEST_SKIP_FILTERS=command_safety::powershell_parser::tests::,suite::code_mode::code_mode_can_call_hidden_dynamic_tools,tests::windows_tests::conpty_ctrl_c_interrupts_powershell_foreground_child
common:ci-windows-cross --platforms=//:windows_x86_64_gnullvm
common:ci-windows-cross --extra_execution_platforms=//:rbe,//:windows_x86_64_msvc
common:ci-windows-cross --extra_toolchains=//:windows_gnullvm_tests_on_msvc_host_toolchain
+1 -1
View File
@@ -3,7 +3,7 @@ name: code-review
description: Run a final code review on a pull request
---
Use subagents to review code using all code-review-* skills in this repository other than this orchestrator. One subagent per skill. Pass full skill path to subagents. Use xhigh reasoning.
Use subagents to review code using all code-review-* skills other than this orchestrator. One subagent per skill. Pass full skill path to subagents. Use xhigh reasoning.
You must return every single issue from every subagent. You can return an unlimited number of findings.
Use raw Markdown to report findings.
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env python3
"""Fail a terminal CI job unless every serialized dependency succeeded.
Parent workflows pass GitHub's `toJSON(needs)` object through the NEEDS
environment variable. Treat skipped and cancelled dependencies as failures too:
for a required fan-in job, only an explicit success is safe to accept.
"""
import json
import os
def main() -> None:
# Keep result policy in one script so blocking-ci and postmerge-ci cannot
# drift in how they interpret dependency conclusions.
needs = json.loads(os.environ["NEEDS"])
failures = sorted(
(name, dependency["result"])
for name, dependency in needs.items()
if dependency["result"] != "success"
)
if failures:
print("CI dependencies did not succeed:")
for name, result in failures:
print(f"{name}: {result}")
raise SystemExit(1)
print("All CI dependencies succeeded.")
if __name__ == "__main__":
main()
+87 -9
View File
@@ -1,12 +1,44 @@
#!/usr/bin/env python3
"""Decide which V8 canary work is needed for a commit range.
The workflow deliberately has no trigger-level path filters because it is both
directly triggered for pull requests and called by postmerge-ci. Keeping the
patterns here gives those entrypoints one source of truth; unrelated events
still run metadata but skip the expensive build matrices.
"""
import argparse
import subprocess
import tomllib
from fnmatch import fnmatchcase
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
# These patterns replace the old pull_request/push path filters. Include parent
# workflow changes because they can alter whether the canary is invoked.
CANARY_PATH_PATTERNS = {
".bazelrc",
".github/actions/setup-bazel-ci/**",
".github/scripts/run_bazel_with_buildbuddy.py",
".github/scripts/rusty_v8_bazel.py",
".github/scripts/rusty_v8_module_bazel.py",
".github/scripts/v8_canary_changes.py",
".github/workflows/postmerge-ci.yml",
".github/workflows/rusty-v8-release.yml",
".github/workflows/v8-canary.yml",
"MODULE.bazel",
"MODULE.bazel.lock",
"codex-rs/Cargo.toml",
"patches/BUILD.bazel",
"patches/llvm_*.patch",
"patches/rules_cc_*.patch",
"patches/v8_*.patch",
"third_party/v8/**",
}
# Windows source builds are a narrower, more expensive subset of the canary.
# A V8 version change also requires them even when no path below changed.
WINDOWS_SOURCE_BUILD_PATHS = {
".github/scripts/rusty_v8_bazel.py",
".github/scripts/rusty_v8_module_bazel.py",
@@ -16,6 +48,30 @@ WINDOWS_SOURCE_BUILD_PATHS = {
}
def matching_canary_paths(changed_files: set[str]) -> set[str]:
"""Return changed paths that require the general V8 build matrix."""
return {
path
for path in changed_files
if any(fnmatchcase(path, pattern) for pattern in CANARY_PATH_PATTERNS)
}
def canary_required(
changed_files: set[str],
base_v8_version: str,
head_v8_version: str,
*,
force: bool = False,
) -> bool:
"""Return whether the general V8 build matrix should run."""
return (
force
or base_v8_version != head_v8_version
or bool(matching_canary_paths(changed_files))
)
def resolved_v8_version(cargo_lock: bytes) -> str:
versions = sorted(
{
@@ -36,6 +92,7 @@ def windows_source_required(
*,
force: bool = False,
) -> bool:
"""Return whether Windows must rebuild rusty_v8 from source."""
return (
force
or base_v8_version != head_v8_version
@@ -58,6 +115,8 @@ def merge_base(base: str, head: str, *, root: Path = ROOT) -> str:
def changed_files(base: str, head: str, *, root: Path = ROOT) -> set[str]:
# Three-dot diff gives PRs merge-base semantics while remaining equivalent
# to before/after for ordinary linear pushes to main.
output = git_output(
"diff",
"--name-only",
@@ -79,25 +138,44 @@ def parse_args() -> argparse.Namespace:
def main() -> None:
args = parse_args()
if args.force:
required = True
reason = "manual workflow dispatch"
# workflow_dispatch has no comparison range, and callers use it as a
# manual retry path, so it intentionally runs every variant.
canary = True
canary_reason = "manual workflow dispatch"
windows_source = True
windows_source_reason = "manual workflow dispatch"
elif not args.base or not args.head:
raise SystemExit("--base and --head are required unless --force is set")
else:
files = changed_files(args.base, args.head)
base_version = v8_version_at_revision(merge_base(args.base, args.head))
head_version = v8_version_at_revision(args.head)
required = windows_source_required(files, base_version, head_version)
matched_canary_paths = sorted(matching_canary_paths(files))
canary = canary_required(files, base_version, head_version)
windows_source = windows_source_required(files, base_version, head_version)
if base_version != head_version:
reason = f"v8 version changed from {base_version} to {head_version}"
canary_reason = (
f"v8 version changed from {base_version} to {head_version}"
)
windows_source_reason = canary_reason
else:
matched_paths = sorted(files & WINDOWS_SOURCE_BUILD_PATHS)
reason = (
", ".join(matched_paths) if matched_paths else "no relevant changes"
canary_reason = (
", ".join(matched_canary_paths)
if matched_canary_paths
else "no relevant changes"
)
matched_windows_paths = sorted(files & WINDOWS_SOURCE_BUILD_PATHS)
windows_source_reason = (
", ".join(matched_windows_paths)
if matched_windows_paths
else "no relevant changes"
)
print(f"windows_source_required={str(required).lower()}")
print(f"windows_source_reason={reason}")
print(f"canary_required={str(canary).lower()}")
print(f"canary_reason={canary_reason}")
print(f"windows_source_required={str(windows_source).lower()}")
print(f"windows_source_reason={windows_source_reason}")
if __name__ == "__main__":
+1 -4
View File
@@ -4,10 +4,7 @@ name: Bazel
# https://github.com/cerisier/toolchains_llvm_bootstrapped/blob/main/.github/workflows/ci.yaml
on:
pull_request: {}
push:
branches:
- main
workflow_call:
workflow_dispatch:
concurrency:
+15 -4
View File
@@ -1,7 +1,7 @@
name: blob-size-policy
on:
pull_request: {}
workflow_call:
jobs:
check:
@@ -14,13 +14,24 @@ jobs:
fetch-depth: 0
persist-credentials: false
- name: Determine PR comparison range
- name: Determine comparison range
id: range
shell: bash
run: |
set -euo pipefail
echo "base=${{ github.event.pull_request.base.sha }}" >> "$GITHUB_OUTPUT"
echo "head=${{ github.event.pull_request.head.sha }}" >> "$GITHUB_OUTPUT"
# PRs inspect the proposed diff; main pushes inspect only the commit
# range that just landed. Both paths feed the same blob-size checker.
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
base='${{ github.event.pull_request.base.sha }}'
head='${{ github.event.pull_request.head.sha }}'
else
base='${{ github.event.before }}'
head='${{ github.sha }}'
fi
echo "base=$base" >> "$GITHUB_OUTPUT"
echo "head=$head" >> "$GITHUB_OUTPUT"
- name: Check changed blob sizes
env:
+79
View File
@@ -0,0 +1,79 @@
name: blocking-ci
# This is the single entrypoint for checks that block a PR merge. It also runs
# after pushes to main so the same check family stays grouped in the Actions UI.
on:
pull_request: {}
push:
branches: [main]
jobs:
# Keep reusable workflow calls alphabetized. The `required` job below is the
# version-controlled list that the main-branch ruleset should require.
bazel:
name: Bazel
uses: ./.github/workflows/bazel.yml
secrets: inherit
blob-size-policy:
name: Blob size policy
uses: ./.github/workflows/blob-size-policy.yml
secrets: inherit
cargo-deny:
name: cargo-deny
uses: ./.github/workflows/cargo-deny.yml
secrets: inherit
codespell:
name: Codespell
uses: ./.github/workflows/codespell.yml
secrets: inherit
repo-checks:
name: repo-checks
uses: ./.github/workflows/repo-checks.yml
secrets: inherit
rust-ci:
name: rust-ci
uses: ./.github/workflows/rust-ci.yml
secrets: inherit
sdk:
name: sdk
uses: ./.github/workflows/sdk.yml
secrets: inherit
required:
name: CI required
# Without `always()`, GitHub skips this job after a failed dependency and a
# required check can appear successful instead of reporting the failure.
if: ${{ always() }}
needs:
- bazel
- blob-size-policy
- cargo-deny
- codespell
- repo-checks
- rust-ci
- sdk
runs-on: ubuntu-24.04
steps:
# Keep the helper on the same revision as the caller and child workflows.
# CI workflow uploads are restricted, so this repository does not need a
# separate trusted-base checkout for the terminal policy step. Using the
# PR head also lets the introducing PR exercise a newly added helper.
#
# During the initial rollout, PR branches created before
# check_ci_results.py exists must rebase onto main before this gate can
# run.
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
persist-credentials: false
- name: Require successful dependencies
env:
NEEDS: ${{ toJSON(needs) }}
run: python3 .github/scripts/check_ci_results.py
+1 -4
View File
@@ -1,10 +1,7 @@
name: cargo-deny
on:
pull_request:
push:
branches:
- main
workflow_call:
# Cargo's libgit2 transport has been flaky when fetching git dependencies with
# nested submodules. Prefer the system git CLI across every Cargo invocation.
+1 -4
View File
@@ -3,10 +3,7 @@
name: Codespell
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_call:
permissions:
contents: read
+40
View File
@@ -0,0 +1,40 @@
name: postmerge-ci
# This is the single entrypoint for main-only CI that is intentionally outside
# the merge-blocking suite. It keeps the broader postmerge signal in one run.
on:
push:
branches: [main]
jobs:
# Keep reusable workflow calls alphabetized. Each child retains its own
# workflow_dispatch trigger so maintainers can rerun flaky suites directly.
rust-ci-full:
name: rust-ci-full
uses: ./.github/workflows/rust-ci-full.yml
secrets: inherit
v8-canary:
name: v8-canary
uses: ./.github/workflows/v8-canary.yml
secrets: inherit
results:
name: Postmerge CI results
needs:
- rust-ci-full
- v8-canary
if: ${{ always() }}
runs-on: ubuntu-24.04
steps:
# Postmerge runs use the pushed main commit, so this helper always comes
# from the same revision that defined the parent workflow.
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.sha }}
persist-credentials: false
- name: Require successful dependencies
env:
NEEDS: ${{ toJSON(needs) }}
run: python3 .github/scripts/check_ci_results.py
@@ -1,8 +1,7 @@
name: ci
name: repo-checks
on:
pull_request: {}
push: { branches: [main] }
workflow_call:
jobs:
build-test:
+3 -1
View File
@@ -1,8 +1,10 @@
name: rust-ci-full
on:
workflow_call:
push:
branches:
- main
# Main pushes enter through postmerge-ci. Keep this opt-in branch trigger
# for developers who want the full suite before merging.
- "**full-ci**"
workflow_dispatch:
+1 -1
View File
@@ -1,6 +1,6 @@
name: rust-ci
on:
pull_request: {}
workflow_call:
workflow_dispatch:
# Cargo's libgit2 transport has been flaky when fetching git dependencies with
+1 -3
View File
@@ -1,9 +1,7 @@
name: sdk
on:
push:
branches: [main]
pull_request: {}
workflow_call:
jobs:
python-sdk:
+15 -39
View File
@@ -1,44 +1,12 @@
name: v8-canary
# Do not use trigger-level path filters here. This workflow is also called by
# postmerge-ci, and GitHub cannot share a path filter between pull_request and
# workflow_call. v8_canary_changes.py is the single source of truth instead;
# unrelated events run only the cheap metadata job below.
on:
pull_request:
paths:
- ".bazelrc"
- ".github/actions/setup-bazel-ci/**"
- ".github/scripts/run_bazel_with_buildbuddy.py"
- ".github/scripts/rusty_v8_bazel.py"
- ".github/scripts/rusty_v8_module_bazel.py"
- ".github/scripts/v8_canary_changes.py"
- ".github/workflows/rusty-v8-release.yml"
- ".github/workflows/v8-canary.yml"
- "MODULE.bazel"
- "MODULE.bazel.lock"
- "codex-rs/Cargo.toml"
- "patches/BUILD.bazel"
- "patches/llvm_*.patch"
- "patches/rules_cc_*.patch"
- "patches/v8_*.patch"
- "third_party/v8/**"
push:
branches:
- main
paths:
- ".bazelrc"
- ".github/actions/setup-bazel-ci/**"
- ".github/scripts/run_bazel_with_buildbuddy.py"
- ".github/scripts/rusty_v8_bazel.py"
- ".github/scripts/rusty_v8_module_bazel.py"
- ".github/scripts/v8_canary_changes.py"
- ".github/workflows/rusty-v8-release.yml"
- ".github/workflows/v8-canary.yml"
- "MODULE.bazel"
- "MODULE.bazel.lock"
- "codex-rs/Cargo.toml"
- "patches/BUILD.bazel"
- "patches/llvm_*.patch"
- "patches/rules_cc_*.patch"
- "patches/v8_*.patch"
- "third_party/v8/**"
workflow_call:
pull_request: {}
workflow_dispatch:
# Cargo's libgit2 transport has been flaky when fetching git dependencies with
@@ -54,6 +22,10 @@ jobs:
metadata:
runs-on: ubuntu-latest
outputs:
# A stale PR head can contain the old detector, which does not emit this
# output. Missing must mean "run" so older branches cannot silently skip
# the expensive V8 coverage while reporting success.
canary_required: ${{ steps.changes.outputs.canary_required || 'true' }}
v8_version: ${{ steps.v8_version.outputs.version }}
windows_source_required: ${{ steps.changes.outputs.windows_source_required }}
@@ -77,7 +49,7 @@ jobs:
version="$(python3 .github/scripts/rusty_v8_bazel.py resolved-v8-crate-version)"
echo "version=${version}" >> "$GITHUB_OUTPUT"
- name: Detect whether Windows source artifacts need rebuilding
- name: Detect V8 canary changes
id: changes
env:
BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
@@ -87,6 +59,8 @@ jobs:
run: |
set -euo pipefail
# Manual runs have no meaningful before/after range. Force every V8
# variant so workflow_dispatch remains a reliable retry path.
if [[ "${EVENT_NAME}" == "workflow_dispatch" ]]; then
output="$(python3 .github/scripts/v8_canary_changes.py --force)"
else
@@ -104,6 +78,8 @@ jobs:
build:
name: Build ${{ matrix.variant }} ${{ matrix.target }}
needs: metadata
# Metadata always runs; only relevant changes pay for the large matrix.
if: ${{ needs.metadata.outputs.canary_required == 'true' }}
runs-on: ${{ matrix.runner }}
permissions:
contents: read
+15
View File
@@ -2314,6 +2314,7 @@ name = "codex-cli"
version = "0.0.0"
dependencies = [
"anyhow",
"app_test_support",
"assert_cmd",
"assert_matches",
"clap",
@@ -2515,6 +2516,17 @@ dependencies = [
[[package]]
name = "codex-code-mode-host"
version = "0.0.0"
dependencies = [
"anyhow",
"codex-code-mode",
"codex-code-mode-protocol",
"codex-protocol",
"codex-utils-cargo-bin",
"pretty_assertions",
"serde_json",
"tokio",
"tokio-util",
]
[[package]]
name = "codex-code-mode-protocol"
@@ -2921,7 +2933,9 @@ dependencies = [
"opentelemetry_sdk",
"pretty_assertions",
"prost 0.14.3",
"rcgen",
"reqwest 0.12.28",
"rustls",
"serde",
"serde_json",
"serial_test",
@@ -3359,6 +3373,7 @@ name = "codex-mcp-extension"
version = "0.0.0"
dependencies = [
"codex-config",
"codex-connectors-extension",
"codex-core",
"codex-core-plugins",
"codex-exec-server",
+1
View File
@@ -166,6 +166,7 @@ codex-code-mode-protocol = { path = "code-mode-protocol" }
codex-home = { path = "codex-home" }
codex-config = { path = "config" }
codex-connectors = { path = "connectors" }
codex-connectors-extension = { path = "ext/connectors" }
codex-context-fragments = { path = "context-fragments" }
codex-core = { path = "core" }
codex-core-api = { path = "core-api" }
@@ -198,6 +198,7 @@ fn sample_thread_with_metadata(
parent_thread_id,
preview: "first prompt".to_string(),
ephemeral,
history_mode: Default::default(),
model_provider: "openai".to_string(),
created_at: 1,
updated_at: 2,
@@ -558,6 +559,7 @@ async fn ingest_rejected_turn_steer(
response: Box::new(sample_thread_resume_response(
"thread-2", /*ephemeral*/ false, "gpt-5",
)),
thread_originator: None,
},
out,
)
@@ -631,6 +633,7 @@ async fn ingest_turn_prerequisites(
response: Box::new(sample_thread_start_response(
"thread-2", /*ephemeral*/ false, "gpt-5",
)),
thread_originator: None,
},
out,
)
@@ -654,6 +657,7 @@ async fn ingest_turn_prerequisites(
connection_id: 7,
request_id: RequestId::Integer(3),
response: Box::new(sample_turn_start_response("turn-2")),
thread_originator: None,
},
out,
)
@@ -720,6 +724,7 @@ async fn ingest_review_prerequisites(
response: Box::new(sample_thread_start_response(
"thread-1", /*ephemeral*/ false, "gpt-5",
)),
thread_originator: None,
},
events,
)
@@ -1652,6 +1657,7 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize
/*ephemeral*/ false,
"gpt-5",
)),
thread_originator: None,
},
&mut events,
)
@@ -1697,6 +1703,7 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize
response: Box::new(sample_thread_resume_response(
"thread-1", /*ephemeral*/ true, "gpt-5",
)),
thread_originator: None,
},
&mut events,
)
@@ -1741,6 +1748,157 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize
);
}
#[tokio::test]
async fn thread_originator_overrides_shared_connection_across_thread_events() {
let mut reducer = AnalyticsReducer::default();
let mut events = Vec::new();
reducer
.ingest(sample_initialize_fact(/*connection_id*/ 7), &mut events)
.await;
for (request_id, thread_id, thread_originator) in [
(1, "thread-work", Some(TEST_PRODUCT_CLIENT_ID.to_string())),
(2, "thread-default", None),
] {
reducer
.ingest(
AnalyticsFact::ClientResponse {
connection_id: 7,
request_id: RequestId::Integer(request_id),
response: Box::new(sample_thread_start_response(
thread_id, /*ephemeral*/ false, "gpt-5",
)),
thread_originator,
},
&mut events,
)
.await;
}
let initialized = serde_json::to_value(&events).expect("serialize thread events");
assert_eq!(
initialized
.as_array()
.expect("thread events")
.iter()
.map(|event| {
json!({
"thread_id": event["event_params"]["thread_id"],
"app_server_client": event["event_params"]["app_server_client"],
})
})
.collect::<Vec<_>>(),
vec![
json!({
"thread_id": "thread-work",
"app_server_client": {
"product_client_id": TEST_PRODUCT_CLIENT_ID,
"client_name": "codex-tui",
"client_version": "1.0.0",
"rpc_transport": "websocket",
"experimental_api_enabled": false,
},
}),
json!({
"thread_id": "thread-default",
"app_server_client": {
"product_client_id": DEFAULT_ORIGINATOR,
"client_name": "codex-tui",
"client_version": "1.0.0",
"rpc_transport": "websocket",
"experimental_api_enabled": false,
},
}),
]
);
events.clear();
reducer
.ingest(
AnalyticsFact::ClientRequest {
connection_id: 7,
request_id: RequestId::Integer(3),
request: Box::new(sample_turn_start_request(
"thread-work",
/*request_id*/ 3,
)),
},
&mut events,
)
.await;
reducer
.ingest(
AnalyticsFact::ClientResponse {
connection_id: 7,
request_id: RequestId::Integer(3),
response: Box::new(sample_turn_start_response("turn-1")),
thread_originator: None,
},
&mut events,
)
.await;
ingest_completed_command_execution_item(&mut reducer, &mut events, "thread-work", "item-work")
.await;
ingest_complete_child_turn(&mut reducer, &mut events, "thread-work", "turn-1").await;
reducer
.ingest(
AnalyticsFact::Custom(CustomAnalyticsFact::Compaction(Box::new(
CodexCompactionEvent {
thread_id: "thread-work".to_string(),
turn_id: "turn-compact".to_string(),
trigger: CompactionTrigger::Manual,
reason: CompactionReason::UserRequested,
implementation: CompactionImplementation::Responses,
phase: CompactionPhase::StandaloneTurn,
strategy: CompactionStrategy::Memento,
status: CompactionStatus::Completed,
codex_error_kind: None,
codex_error_http_status_code: None,
active_context_tokens_before: 131_000,
active_context_tokens_after: 64_000,
retained_image_count: None,
compaction_summary_tokens: None,
cached_input_tokens: None,
started_at: 100,
completed_at: 101,
duration_ms: Some(1200),
},
))),
&mut events,
)
.await;
let lifecycle = serde_json::to_value(&events).expect("serialize lifecycle events");
assert_eq!(
lifecycle
.as_array()
.expect("lifecycle events")
.iter()
.map(|event| {
json!({
"event_type": event["event_type"],
"product_client_id":
event["event_params"]["app_server_client"]["product_client_id"],
})
})
.collect::<Vec<_>>(),
vec![
json!({
"event_type": "codex_command_execution_event",
"product_client_id": TEST_PRODUCT_CLIENT_ID,
}),
json!({
"event_type": "codex_turn_event",
"product_client_id": TEST_PRODUCT_CLIENT_ID,
}),
json!({
"event_type": "codex_compaction_event",
"product_client_id": TEST_PRODUCT_CLIENT_ID,
}),
]
);
}
#[tokio::test]
async fn unrelated_client_requests_are_ignored_by_reducer() {
let mut reducer = AnalyticsReducer::default();
@@ -1767,6 +1925,7 @@ async fn unrelated_client_requests_are_ignored_by_reducer() {
connection_id: 7,
request_id: RequestId::Integer(3),
response: Box::new(sample_turn_start_response("turn-2")),
thread_originator: None,
},
&mut events,
)
@@ -1792,6 +1951,7 @@ async fn unrelated_client_responses_are_ignored_by_reducer() {
response: Box::new(ClientResponsePayload::ThreadArchive(
ThreadArchiveResponse {},
)),
thread_originator: None,
},
&mut events,
)
@@ -1851,6 +2011,7 @@ async fn compaction_event_ingests_custom_fact() {
Some(AppServerThreadSource::Subagent),
Some(parent_thread_id.to_string()),
)),
thread_originator: None,
},
&mut events,
)
@@ -1971,6 +2132,7 @@ async fn guardian_review_event_ingests_custom_fact_with_optional_target_item() {
/*ephemeral*/ false,
"gpt-5",
)),
thread_originator: None,
},
&mut events,
)
@@ -2499,6 +2661,7 @@ async fn item_review_summaries_do_not_cross_threads_with_reused_item_ids() {
response: Box::new(sample_thread_start_response(
"thread-2", /*ephemeral*/ false, "gpt-5",
)),
thread_originator: None,
},
&mut events,
)
@@ -2749,7 +2912,7 @@ async fn subagent_thread_started_publishes_without_initialize() {
}
#[tokio::test]
async fn subagent_events_use_inherited_connection_unless_turn_connection_is_explicit() {
async fn subagent_events_keep_thread_originator_with_explicit_turn_connection() {
let mut reducer = AnalyticsReducer::default();
let mut events = Vec::new();
let parent_thread_id =
@@ -2786,6 +2949,7 @@ async fn subagent_events_use_inherited_connection_unless_turn_connection_is_expl
/*ephemeral*/ false,
"gpt-5",
)),
thread_originator: None,
},
&mut events,
)
@@ -2908,6 +3072,7 @@ async fn subagent_events_use_inherited_connection_unless_turn_connection_is_expl
connection_id: 8,
request_id: RequestId::Integer(3),
response: Box::new(sample_turn_start_response("turn-explicit")),
thread_originator: None,
},
&mut events,
)
@@ -2918,7 +3083,11 @@ async fn subagent_events_use_inherited_connection_unless_turn_connection_is_expl
};
assert_eq!(
event.event_params.app_server_client.product_client_id,
DEFAULT_ORIGINATOR
"parent-client"
);
assert_eq!(
event.event_params.app_server_client.client_name.as_deref(),
Some("codex-tui")
);
}
@@ -3868,6 +4037,7 @@ async fn accepted_turn_steer_emits_expected_event() {
connection_id: 7,
request_id: RequestId::Integer(4),
response: Box::new(sample_turn_steer_response("turn-2")),
thread_originator: None,
},
&mut out,
)
@@ -4039,6 +4209,7 @@ async fn turn_start_error_response_discards_pending_start_request() {
connection_id: 7,
request_id: RequestId::Integer(3),
response: Box::new(sample_turn_start_response("turn-2")),
thread_originator: None,
},
&mut out,
)
@@ -4367,6 +4538,7 @@ async fn accepted_steers_increment_turn_steer_count() {
connection_id: 7,
request_id: RequestId::Integer(4),
response: Box::new(sample_turn_steer_response("turn-2")),
thread_originator: None,
},
&mut out,
)
@@ -4414,6 +4586,7 @@ async fn accepted_steers_increment_turn_steer_count() {
connection_id: 7,
request_id: RequestId::Integer(6),
response: Box::new(sample_turn_steer_response("turn-2")),
thread_originator: None,
},
&mut out,
)
+26
View File
@@ -435,6 +435,31 @@ impl AnalyticsEventsClient {
connection_id: u64,
request_id: RequestId,
response: ClientResponsePayload,
) {
self.track_response_inner(
connection_id,
request_id,
response,
/*thread_originator*/ None,
);
}
pub fn track_response_with_thread_originator(
&self,
connection_id: u64,
request_id: RequestId,
response: ClientResponsePayload,
thread_originator: String,
) {
self.track_response_inner(connection_id, request_id, response, Some(thread_originator));
}
fn track_response_inner(
&self,
connection_id: u64,
request_id: RequestId,
response: ClientResponsePayload,
thread_originator: Option<String>,
) {
if !matches!(
response,
@@ -450,6 +475,7 @@ impl AnalyticsEventsClient {
connection_id,
request_id,
response: Box::new(response),
thread_originator,
});
}
+1
View File
@@ -283,6 +283,7 @@ fn sample_thread(thread_id: &str) -> Thread {
parent_thread_id: None,
preview: "first prompt".to_string(),
ephemeral: false,
history_mode: Default::default(),
model_provider: "openai".to_string(),
created_at: 1,
updated_at: 2,
+1
View File
@@ -464,6 +464,7 @@ pub(crate) enum AnalyticsFact {
connection_id: u64,
request_id: RequestId,
response: Box<ClientResponsePayload>,
thread_originator: Option<String>,
},
ErrorResponse {
connection_id: u64,
+76 -38
View File
@@ -165,6 +165,20 @@ struct ConnectionState {
struct ThreadAnalyticsState {
connection_id: Option<u64>,
metadata: Option<ThreadMetadataState>,
originator: Option<String>,
}
impl ThreadAnalyticsState {
fn app_server_client(
&self,
connection_state: &ConnectionState,
) -> CodexAppServerClientMetadata {
let mut app_server_client = connection_state.app_server_client.clone();
if let Some(originator) = self.originator.as_ref() {
app_server_client.product_client_id.clone_from(originator);
}
app_server_client
}
}
#[derive(Clone, Copy)]
@@ -434,9 +448,11 @@ impl AnalyticsReducer {
connection_id,
request_id,
response,
thread_originator,
} => {
if let Some(response) = response.into_client_response(request_id) {
self.ingest_response(connection_id, response, out).await;
self.ingest_response(connection_id, response, thread_originator, out)
.await;
}
}
AnalyticsFact::ErrorResponse {
@@ -575,6 +591,9 @@ impl AnalyticsReducer {
.and_then(|parent_thread_id| self.threads.get(parent_thread_id))
.and_then(|thread| thread.connection_id);
let thread_state = self.threads.entry(input.thread_id.clone()).or_default();
thread_state
.originator
.get_or_insert_with(|| input.product_client_id.clone());
thread_state
.metadata
.get_or_insert_with(|| ThreadMetadataState {
@@ -597,7 +616,7 @@ impl AnalyticsReducer {
input: GuardianReviewEventParams,
out: &mut Vec<TrackEventRequest>,
) {
let Some((connection_state, thread_metadata)) =
let Some((connection_state, thread_state, thread_metadata)) =
self.thread_context_or_warn(AnalyticsDropSite::guardian(&input))
else {
return;
@@ -607,7 +626,7 @@ impl AnalyticsReducer {
event_type: "codex_guardian_review",
event_params: GuardianReviewEventPayload {
session_id: thread_metadata.session_id.clone(),
app_server_client: connection_state.app_server_client.clone(),
app_server_client: thread_state.app_server_client(connection_state),
runtime: connection_state.runtime.clone(),
guardian_review: input,
},
@@ -874,6 +893,7 @@ impl AnalyticsReducer {
&mut self,
connection_id: u64,
response: ClientResponse,
thread_originator: Option<String>,
out: &mut Vec<TrackEventRequest>,
) {
match response {
@@ -883,6 +903,7 @@ impl AnalyticsReducer {
response.thread,
response.model,
ThreadInitializationMode::New,
thread_originator,
out,
);
}
@@ -892,6 +913,7 @@ impl AnalyticsReducer {
response.thread,
response.model,
ThreadInitializationMode::Resumed,
thread_originator,
out,
);
}
@@ -901,6 +923,7 @@ impl AnalyticsReducer {
response.thread,
response.model,
ThreadInitializationMode::Forked,
thread_originator,
out,
);
}
@@ -1248,7 +1271,7 @@ impl AnalyticsReducer {
else {
return;
};
let Some((connection_state, thread_metadata)) = self
let Some((connection_state, thread_state, thread_metadata)) = self
.thread_context_or_warn(AnalyticsDropSite::tool_item(&notification, item_id))
else {
return;
@@ -1260,6 +1283,7 @@ impl AnalyticsReducer {
started_at_ms,
completed_at_ms,
connection_state,
thread_state,
thread_metadata,
review_summary: self.item_review_summaries.get(&key),
}) {
@@ -1316,6 +1340,7 @@ impl AnalyticsReducer {
thread: codex_app_server_protocol::Thread,
model: String,
initialization_mode: ThreadInitializationMode,
thread_originator: Option<String>,
out: &mut Vec<TrackEventRequest>,
) {
let session_source: SessionSource = thread.source.into();
@@ -1333,20 +1358,20 @@ impl AnalyticsReducer {
parent_thread_id,
initialization_mode,
);
self.threads.insert(
thread_id.clone(),
ThreadAnalyticsState {
connection_id: Some(connection_id),
metadata: Some(thread_metadata.clone()),
},
);
let thread_state = self.threads.entry(thread_id.clone()).or_default();
if let Some(originator) = thread_originator {
thread_state.originator = Some(originator);
}
thread_state.connection_id = Some(connection_id);
thread_state.metadata = Some(thread_metadata.clone());
let app_server_client = thread_state.app_server_client(connection_state);
out.push(TrackEventRequest::ThreadInitialized(
ThreadInitializedEvent {
event_type: "codex_thread_initialized",
event_params: ThreadInitializedEventParams {
thread_id,
session_id,
app_server_client: connection_state.app_server_client.clone(),
app_server_client,
runtime: connection_state.runtime.clone(),
model,
ephemeral: thread.ephemeral,
@@ -1362,7 +1387,7 @@ impl AnalyticsReducer {
}
fn ingest_compaction(&mut self, input: CodexCompactionEvent, out: &mut Vec<TrackEventRequest>) {
let Some((connection_state, thread_metadata)) =
let Some((connection_state, thread_state, thread_metadata)) =
self.thread_context_or_warn(AnalyticsDropSite::compaction(&input))
else {
return;
@@ -1373,7 +1398,7 @@ impl AnalyticsReducer {
event_params: codex_compaction_event_params(
input,
thread_metadata.session_id.clone(),
connection_state.app_server_client.clone(),
thread_state.app_server_client(connection_state),
connection_state.runtime.clone(),
thread_metadata.thread_source.clone(),
thread_metadata.subagent_source.clone(),
@@ -1384,7 +1409,7 @@ impl AnalyticsReducer {
}
fn ingest_goal(&mut self, input: CodexGoalEvent, out: &mut Vec<TrackEventRequest>) {
let Some((connection_state, thread_metadata)) =
let Some((connection_state, thread_state, thread_metadata)) =
self.thread_context_or_warn(AnalyticsDropSite::goal(&input))
else {
return;
@@ -1394,7 +1419,7 @@ impl AnalyticsReducer {
event_params: codex_goal_event_params(
input,
thread_metadata.session_id.clone(),
connection_state.app_server_client.clone(),
thread_state.app_server_client(connection_state),
connection_state.runtime.clone(),
thread_metadata.thread_source.clone(),
thread_metadata.subagent_source.clone(),
@@ -1483,11 +1508,11 @@ impl AnalyticsReducer {
return;
};
let drop_site = AnalyticsDropSite::turn_steer(&pending_request.thread_id);
let Some(thread_metadata) = self
.threads
.get(drop_site.thread_id)
.and_then(|thread| thread.metadata.as_ref())
else {
let Some(thread_state) = self.threads.get(drop_site.thread_id) else {
warn_missing_analytics_context(&drop_site, MissingAnalyticsContext::ThreadMetadata);
return;
};
let Some(thread_metadata) = thread_state.metadata.as_ref() else {
warn_missing_analytics_context(&drop_site, MissingAnalyticsContext::ThreadMetadata);
return;
};
@@ -1498,7 +1523,7 @@ impl AnalyticsReducer {
session_id: thread_metadata.session_id.clone(),
expected_turn_id: Some(pending_request.expected_turn_id),
accepted_turn_id,
app_server_client: connection_state.app_server_client.clone(),
app_server_client: thread_state.app_server_client(connection_state),
runtime: connection_state.runtime.clone(),
thread_source: thread_metadata.thread_source.clone(),
subagent_source: thread_metadata.subagent_source.clone(),
@@ -1529,7 +1554,7 @@ impl AnalyticsReducer {
&pending_review,
);
}
let Some((connection_state, thread_metadata)) =
let Some((connection_state, thread_state, thread_metadata)) =
self.thread_context_or_warn(AnalyticsDropSite::review(&pending_review))
else {
return;
@@ -1541,7 +1566,7 @@ impl AnalyticsReducer {
turn_id: pending_review.turn_id,
item_id: pending_review.item_id,
review_id: pending_review.review_id,
app_server_client: connection_state.app_server_client.clone(),
app_server_client: thread_state.app_server_client(connection_state),
runtime: connection_state.runtime.clone(),
thread_source: thread_metadata.thread_source.clone(),
subagent_source: thread_metadata.subagent_source.clone(),
@@ -1610,18 +1635,18 @@ impl AnalyticsReducer {
);
return;
};
let Some(thread_metadata) = self
.threads
.get(drop_site.thread_id)
.and_then(|thread| thread.metadata.as_ref())
else {
let Some(thread_state) = self.threads.get(drop_site.thread_id) else {
warn_missing_analytics_context(&drop_site, MissingAnalyticsContext::ThreadMetadata);
return;
};
let Some(thread_metadata) = thread_state.metadata.as_ref() else {
warn_missing_analytics_context(&drop_site, MissingAnalyticsContext::ThreadMetadata);
return;
};
let turn_event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest {
event_type: "codex_turn_event",
event_params: codex_turn_event_params(
connection_state.app_server_client.clone(),
thread_state.app_server_client(connection_state),
connection_state.runtime.clone(),
turn_id.to_string(),
turn_state,
@@ -1663,17 +1688,18 @@ impl AnalyticsReducer {
fn thread_context_or_warn(
&self,
drop_site: AnalyticsDropSite<'_>,
) -> Option<(&ConnectionState, &ThreadMetadataState)> {
) -> Option<(
&ConnectionState,
&ThreadAnalyticsState,
&ThreadMetadataState,
)> {
let connection_state = self.thread_connection_or_warn(drop_site)?;
let Some(thread_metadata) = self
.threads
.get(drop_site.thread_id)
.and_then(|thread| thread.metadata.as_ref())
else {
let thread_state = self.threads.get(drop_site.thread_id)?;
let Some(thread_metadata) = thread_state.metadata.as_ref() else {
warn_missing_analytics_context(&drop_site, MissingAnalyticsContext::ThreadMetadata);
return None;
};
Some((connection_state, thread_metadata))
Some((connection_state, thread_state, thread_metadata))
}
}
@@ -1743,6 +1769,7 @@ struct ToolItemEventInput<'a> {
started_at_ms: u64,
completed_at_ms: u64,
connection_state: &'a ConnectionState,
thread_state: &'a ThreadAnalyticsState,
thread_metadata: &'a ThreadMetadataState,
review_summary: Option<&'a ItemReviewSummary>,
}
@@ -1755,6 +1782,7 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option<TrackEventRequest> {
started_at_ms,
completed_at_ms,
connection_state,
thread_state,
thread_metadata,
review_summary,
} = input;
@@ -1784,6 +1812,7 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option<TrackEventRequest> {
started_at_ms,
completed_at_ms,
connection_state,
thread_state,
thread_metadata,
review_summary,
},
@@ -1825,6 +1854,7 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option<TrackEventRequest> {
started_at_ms,
completed_at_ms,
connection_state,
thread_state,
thread_metadata,
review_summary,
},
@@ -1866,6 +1896,7 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option<TrackEventRequest> {
started_at_ms,
completed_at_ms,
connection_state,
thread_state,
thread_metadata,
review_summary,
},
@@ -1910,6 +1941,7 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option<TrackEventRequest> {
started_at_ms,
completed_at_ms,
connection_state,
thread_state,
thread_metadata,
review_summary,
},
@@ -1954,6 +1986,7 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option<TrackEventRequest> {
started_at_ms,
completed_at_ms,
connection_state,
thread_state,
thread_metadata,
review_summary,
},
@@ -2009,6 +2042,7 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option<TrackEventRequest> {
started_at_ms,
completed_at_ms,
connection_state,
thread_state,
thread_metadata,
review_summary,
},
@@ -2045,6 +2079,7 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option<TrackEventRequest> {
started_at_ms,
completed_at_ms,
connection_state,
thread_state,
thread_metadata,
review_summary,
},
@@ -2100,6 +2135,7 @@ struct ToolItemContext<'a> {
started_at_ms: u64,
completed_at_ms: u64,
connection_state: &'a ConnectionState,
thread_state: &'a ThreadAnalyticsState,
thread_metadata: &'a ThreadMetadataState,
review_summary: Option<&'a ItemReviewSummary>,
}
@@ -2118,7 +2154,9 @@ fn tool_item_base(
thread_id: thread_id.to_string(),
turn_id: turn_id.to_string(),
item_id,
app_server_client: context.connection_state.app_server_client.clone(),
app_server_client: context
.thread_state
.app_server_client(context.connection_state),
runtime: context.connection_state.runtime.clone(),
thread_source: thread_metadata.thread_source.clone(),
subagent_source: thread_metadata.subagent_source.clone(),
+15 -1
View File
@@ -3673,6 +3673,13 @@
}
],
"description": "Optional client-supplied analytics source classification for this forked thread."
},
"lastTurnId": {
"description": "Optional last turn id to fork through, inclusive.\n\nWhen specified, turns after `last_turn_id` are omitted from the fork. The referenced turn cannot be in progress.",
"type": [
"string",
"null"
]
}
},
"required": [
@@ -3747,6 +3754,13 @@
],
"type": "string"
},
"ThreadHistoryMode": {
"enum": [
"legacy",
"paginated"
],
"type": "string"
},
"ThreadInjectItemsParams": {
"properties": {
"items": {
@@ -6859,4 +6873,4 @@
}
],
"title": "ClientRequest"
}
}
@@ -2438,6 +2438,18 @@
},
"McpToolCallAppContext": {
"properties": {
"actionName": {
"type": [
"string",
"null"
]
},
"appName": {
"type": [
"string",
"null"
]
},
"connectorId": {
"type": "string"
},
@@ -2452,6 +2464,12 @@
"string",
"null"
]
},
"templateId": {
"type": [
"string",
"null"
]
}
},
"required": [
@@ -3899,6 +3917,13 @@
],
"type": "object"
},
"ThreadHistoryMode": {
"enum": [
"legacy",
"paginated"
],
"type": "string"
},
"ThreadId": {
"type": "string"
},
@@ -8414,6 +8414,16 @@
"object",
"null"
]
},
"models": {
"anyOf": [
{
"$ref": "#/definitions/v2/ModelsRequirements"
},
{
"type": "null"
}
]
}
},
"type": "object"
@@ -12248,6 +12258,18 @@
},
"McpToolCallAppContext": {
"properties": {
"actionName": {
"type": [
"string",
"null"
]
},
"appName": {
"type": [
"string",
"null"
]
},
"connectorId": {
"type": "string"
},
@@ -12262,6 +12284,12 @@
"string",
"null"
]
},
"templateId": {
"type": [
"string",
"null"
]
}
},
"required": [
@@ -12816,6 +12844,21 @@
"title": "ModelVerificationNotification",
"type": "object"
},
"ModelsRequirements": {
"properties": {
"newThread": {
"anyOf": [
{
"$ref": "#/definitions/v2/NewThreadModelDefaults"
},
{
"type": "null"
}
]
}
},
"type": "object"
},
"MultiAgentMode": {
"description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.",
"enum": [
@@ -12963,6 +13006,33 @@
],
"type": "string"
},
"NewThreadModelDefaults": {
"properties": {
"model": {
"type": [
"string",
"null"
]
},
"modelReasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/v2/ReasoningEffort"
},
{
"type": "null"
}
]
},
"serviceTier": {
"type": [
"string",
"null"
]
}
},
"type": "object"
},
"NonSteerableTurnKind": {
"enum": [
"review",
@@ -14137,6 +14207,40 @@
"title": "GitPluginSource",
"type": "object"
},
{
"properties": {
"package": {
"type": "string"
},
"registry": {
"description": "Optional HTTPS registry URL. Authentication stays in the user's npm config.",
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"npm"
],
"title": "NpmPluginSourceType",
"type": "string"
},
"version": {
"description": "Optional npm version or version range.",
"type": [
"string",
"null"
]
}
},
"required": [
"package",
"type"
],
"title": "NpmPluginSource",
"type": "object"
},
{
"description": "The plugin is available in the remote catalog. Download metadata is kept server-side and is not exposed through the app-server API.",
"properties": {
@@ -17217,6 +17321,13 @@
}
],
"description": "Optional client-supplied analytics source classification for this forked thread."
},
"lastTurnId": {
"description": "Optional last turn id to fork through, inclusive.\n\nWhen specified, turns after `last_turn_id` are omitted from the fork. The referenced turn cannot be in progress.",
"type": [
"string",
"null"
]
}
},
"required": [
@@ -17494,6 +17605,13 @@
"title": "ThreadGoalUpdatedNotification",
"type": "object"
},
"ThreadHistoryMode": {
"enum": [
"legacy",
"paginated"
],
"type": "string"
},
"ThreadId": {
"type": "string"
},
@@ -20756,4 +20874,4 @@
},
"title": "CodexAppServerProtocol",
"type": "object"
}
}
@@ -4654,6 +4654,16 @@
"object",
"null"
]
},
"models": {
"anyOf": [
{
"$ref": "#/definitions/ModelsRequirements"
},
{
"type": "null"
}
]
}
},
"type": "object"
@@ -8652,6 +8662,18 @@
},
"McpToolCallAppContext": {
"properties": {
"actionName": {
"type": [
"string",
"null"
]
},
"appName": {
"type": [
"string",
"null"
]
},
"connectorId": {
"type": "string"
},
@@ -8666,6 +8688,12 @@
"string",
"null"
]
},
"templateId": {
"type": [
"string",
"null"
]
}
},
"required": [
@@ -9220,6 +9248,21 @@
"title": "ModelVerificationNotification",
"type": "object"
},
"ModelsRequirements": {
"properties": {
"newThread": {
"anyOf": [
{
"$ref": "#/definitions/NewThreadModelDefaults"
},
{
"type": "null"
}
]
}
},
"type": "object"
},
"MultiAgentMode": {
"description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.",
"enum": [
@@ -9367,6 +9410,33 @@
],
"type": "string"
},
"NewThreadModelDefaults": {
"properties": {
"model": {
"type": [
"string",
"null"
]
},
"modelReasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
]
},
"serviceTier": {
"type": [
"string",
"null"
]
}
},
"type": "object"
},
"NonSteerableTurnKind": {
"enum": [
"review",
@@ -10541,6 +10611,40 @@
"title": "GitPluginSource",
"type": "object"
},
{
"properties": {
"package": {
"type": "string"
},
"registry": {
"description": "Optional HTTPS registry URL. Authentication stays in the user's npm config.",
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"npm"
],
"title": "NpmPluginSourceType",
"type": "string"
},
"version": {
"description": "Optional npm version or version range.",
"type": [
"string",
"null"
]
}
},
"required": [
"package",
"type"
],
"title": "NpmPluginSource",
"type": "object"
},
{
"description": "The plugin is available in the remote catalog. Download metadata is kept server-side and is not exposed through the app-server API.",
"properties": {
@@ -14996,6 +15100,13 @@
}
],
"description": "Optional client-supplied analytics source classification for this forked thread."
},
"lastTurnId": {
"description": "Optional last turn id to fork through, inclusive.\n\nWhen specified, turns after `last_turn_id` are omitted from the fork. The referenced turn cannot be in progress.",
"type": [
"string",
"null"
]
}
},
"required": [
@@ -15273,6 +15384,13 @@
"title": "ThreadGoalUpdatedNotification",
"type": "object"
},
"ThreadHistoryMode": {
"enum": [
"legacy",
"paginated"
],
"type": "string"
},
"ThreadId": {
"type": "string"
},
@@ -18534,4 +18652,4 @@
},
"title": "CodexAppServerProtocolV2",
"type": "object"
}
}
@@ -169,6 +169,16 @@
"object",
"null"
]
},
"models": {
"anyOf": [
{
"$ref": "#/definitions/ModelsRequirements"
},
{
"type": "null"
}
]
}
},
"type": "object"
@@ -362,6 +372,21 @@
],
"type": "object"
},
"ModelsRequirements": {
"properties": {
"newThread": {
"anyOf": [
{
"$ref": "#/definitions/NewThreadModelDefaults"
},
{
"type": "null"
}
]
}
},
"type": "object"
},
"NetworkDomainPermission": {
"enum": [
"allow",
@@ -484,6 +509,38 @@
],
"type": "string"
},
"NewThreadModelDefaults": {
"properties": {
"model": {
"type": [
"string",
"null"
]
},
"modelReasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
]
},
"serviceTier": {
"type": [
"string",
"null"
]
}
},
"type": "object"
},
"ReasoningEffort": {
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"ResidencyRequirement": {
"enum": [
"us"
@@ -299,6 +299,18 @@
},
"McpToolCallAppContext": {
"properties": {
"actionName": {
"type": [
"string",
"null"
]
},
"appName": {
"type": [
"string",
"null"
]
},
"connectorId": {
"type": "string"
},
@@ -313,6 +325,12 @@
"string",
"null"
]
},
"templateId": {
"type": [
"string",
"null"
]
}
},
"required": [
@@ -299,6 +299,18 @@
},
"McpToolCallAppContext": {
"properties": {
"actionName": {
"type": [
"string",
"null"
]
},
"appName": {
"type": [
"string",
"null"
]
},
"connectorId": {
"type": "string"
},
@@ -313,6 +325,12 @@
"string",
"null"
]
},
"templateId": {
"type": [
"string",
"null"
]
}
},
"required": [
@@ -413,6 +413,40 @@
"title": "GitPluginSource",
"type": "object"
},
{
"properties": {
"package": {
"type": "string"
},
"registry": {
"description": "Optional HTTPS registry URL. Authentication stays in the user's npm config.",
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"npm"
],
"title": "NpmPluginSourceType",
"type": "string"
},
"version": {
"description": "Optional npm version or version range.",
"type": [
"string",
"null"
]
}
},
"required": [
"package",
"type"
],
"title": "NpmPluginSource",
"type": "object"
},
{
"description": "The plugin is available in the remote catalog. Download metadata is kept server-side and is not exposed through the app-server API.",
"properties": {
@@ -413,6 +413,40 @@
"title": "GitPluginSource",
"type": "object"
},
{
"properties": {
"package": {
"type": "string"
},
"registry": {
"description": "Optional HTTPS registry URL. Authentication stays in the user's npm config.",
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"npm"
],
"title": "NpmPluginSourceType",
"type": "string"
},
"version": {
"description": "Optional npm version or version range.",
"type": [
"string",
"null"
]
}
},
"required": [
"package",
"type"
],
"title": "NpmPluginSource",
"type": "object"
},
{
"description": "The plugin is available in the remote catalog. Download metadata is kept server-side and is not exposed through the app-server API.",
"properties": {
@@ -553,6 +553,40 @@
"title": "GitPluginSource",
"type": "object"
},
{
"properties": {
"package": {
"type": "string"
},
"registry": {
"description": "Optional HTTPS registry URL. Authentication stays in the user's npm config.",
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"npm"
],
"title": "NpmPluginSourceType",
"type": "string"
},
"version": {
"description": "Optional npm version or version range.",
"type": [
"string",
"null"
]
}
},
"required": [
"package",
"type"
],
"title": "NpmPluginSource",
"type": "object"
},
{
"description": "The plugin is available in the remote catalog. Download metadata is kept server-side and is not exposed through the app-server API.",
"properties": {
@@ -369,6 +369,40 @@
"title": "GitPluginSource",
"type": "object"
},
{
"properties": {
"package": {
"type": "string"
},
"registry": {
"description": "Optional HTTPS registry URL. Authentication stays in the user's npm config.",
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"npm"
],
"title": "NpmPluginSourceType",
"type": "string"
},
"version": {
"description": "Optional npm version or version range.",
"type": [
"string",
"null"
]
}
},
"required": [
"package",
"type"
],
"title": "NpmPluginSource",
"type": "object"
},
{
"description": "The plugin is available in the remote catalog. Download metadata is kept server-side and is not exposed through the app-server API.",
"properties": {
@@ -437,6 +437,18 @@
},
"McpToolCallAppContext": {
"properties": {
"actionName": {
"type": [
"string",
"null"
]
},
"appName": {
"type": [
"string",
"null"
]
},
"connectorId": {
"type": "string"
},
@@ -451,6 +463,12 @@
"string",
"null"
]
},
"templateId": {
"type": [
"string",
"null"
]
}
},
"required": [
@@ -168,6 +168,13 @@
}
],
"description": "Optional client-supplied analytics source classification for this forked thread."
},
"lastTurnId": {
"description": "Optional last turn id to fork through, inclusive.\n\nWhen specified, turns after `last_turn_id` are omitted from the fork. The referenced turn cannot be in progress.",
"type": [
"string",
"null"
]
}
},
"required": [
@@ -175,4 +182,4 @@
],
"title": "ThreadForkParams",
"type": "object"
}
}
@@ -541,6 +541,18 @@
},
"McpToolCallAppContext": {
"properties": {
"actionName": {
"type": [
"string",
"null"
]
},
"appName": {
"type": [
"string",
"null"
]
},
"connectorId": {
"type": "string"
},
@@ -555,6 +567,12 @@
"string",
"null"
]
},
"templateId": {
"type": [
"string",
"null"
]
}
},
"required": [
@@ -1170,6 +1188,13 @@
"description": "Extra app-server data for a thread.",
"type": "object"
},
"ThreadHistoryMode": {
"enum": [
"legacy",
"paginated"
],
"type": "string"
},
"ThreadId": {
"type": "string"
},
@@ -463,6 +463,18 @@
},
"McpToolCallAppContext": {
"properties": {
"actionName": {
"type": [
"string",
"null"
]
},
"appName": {
"type": [
"string",
"null"
]
},
"connectorId": {
"type": "string"
},
@@ -477,6 +489,12 @@
"string",
"null"
]
},
"templateId": {
"type": [
"string",
"null"
]
}
},
"required": [
@@ -977,6 +995,13 @@
"description": "Extra app-server data for a thread.",
"type": "object"
},
"ThreadHistoryMode": {
"enum": [
"legacy",
"paginated"
],
"type": "string"
},
"ThreadId": {
"type": "string"
},
@@ -463,6 +463,18 @@
},
"McpToolCallAppContext": {
"properties": {
"actionName": {
"type": [
"string",
"null"
]
},
"appName": {
"type": [
"string",
"null"
]
},
"connectorId": {
"type": "string"
},
@@ -477,6 +489,12 @@
"string",
"null"
]
},
"templateId": {
"type": [
"string",
"null"
]
}
},
"required": [
@@ -977,6 +995,13 @@
"description": "Extra app-server data for a thread.",
"type": "object"
},
"ThreadHistoryMode": {
"enum": [
"legacy",
"paginated"
],
"type": "string"
},
"ThreadId": {
"type": "string"
},
@@ -463,6 +463,18 @@
},
"McpToolCallAppContext": {
"properties": {
"actionName": {
"type": [
"string",
"null"
]
},
"appName": {
"type": [
"string",
"null"
]
},
"connectorId": {
"type": "string"
},
@@ -477,6 +489,12 @@
"string",
"null"
]
},
"templateId": {
"type": [
"string",
"null"
]
}
},
"required": [
@@ -977,6 +995,13 @@
"description": "Extra app-server data for a thread.",
"type": "object"
},
"ThreadHistoryMode": {
"enum": [
"legacy",
"paginated"
],
"type": "string"
},
"ThreadId": {
"type": "string"
},
@@ -541,6 +541,18 @@
},
"McpToolCallAppContext": {
"properties": {
"actionName": {
"type": [
"string",
"null"
]
},
"appName": {
"type": [
"string",
"null"
]
},
"connectorId": {
"type": "string"
},
@@ -555,6 +567,12 @@
"string",
"null"
]
},
"templateId": {
"type": [
"string",
"null"
]
}
},
"required": [
@@ -1170,6 +1188,13 @@
"description": "Extra app-server data for a thread.",
"type": "object"
},
"ThreadHistoryMode": {
"enum": [
"legacy",
"paginated"
],
"type": "string"
},
"ThreadId": {
"type": "string"
},
@@ -463,6 +463,18 @@
},
"McpToolCallAppContext": {
"properties": {
"actionName": {
"type": [
"string",
"null"
]
},
"appName": {
"type": [
"string",
"null"
]
},
"connectorId": {
"type": "string"
},
@@ -477,6 +489,12 @@
"string",
"null"
]
},
"templateId": {
"type": [
"string",
"null"
]
}
},
"required": [
@@ -977,6 +995,13 @@
"description": "Extra app-server data for a thread.",
"type": "object"
},
"ThreadHistoryMode": {
"enum": [
"legacy",
"paginated"
],
"type": "string"
},
"ThreadId": {
"type": "string"
},
@@ -241,6 +241,13 @@
],
"type": "object"
},
"ThreadHistoryMode": {
"enum": [
"legacy",
"paginated"
],
"type": "string"
},
"ThreadSource": {
"type": "string"
},
@@ -541,6 +541,18 @@
},
"McpToolCallAppContext": {
"properties": {
"actionName": {
"type": [
"string",
"null"
]
},
"appName": {
"type": [
"string",
"null"
]
},
"connectorId": {
"type": "string"
},
@@ -555,6 +567,12 @@
"string",
"null"
]
},
"templateId": {
"type": [
"string",
"null"
]
}
},
"required": [
@@ -1170,6 +1188,13 @@
"description": "Extra app-server data for a thread.",
"type": "object"
},
"ThreadHistoryMode": {
"enum": [
"legacy",
"paginated"
],
"type": "string"
},
"ThreadId": {
"type": "string"
},
@@ -463,6 +463,18 @@
},
"McpToolCallAppContext": {
"properties": {
"actionName": {
"type": [
"string",
"null"
]
},
"appName": {
"type": [
"string",
"null"
]
},
"connectorId": {
"type": "string"
},
@@ -477,6 +489,12 @@
"string",
"null"
]
},
"templateId": {
"type": [
"string",
"null"
]
}
},
"required": [
@@ -977,6 +995,13 @@
"description": "Extra app-server data for a thread.",
"type": "object"
},
"ThreadHistoryMode": {
"enum": [
"legacy",
"paginated"
],
"type": "string"
},
"ThreadId": {
"type": "string"
},
@@ -463,6 +463,18 @@
},
"McpToolCallAppContext": {
"properties": {
"actionName": {
"type": [
"string",
"null"
]
},
"appName": {
"type": [
"string",
"null"
]
},
"connectorId": {
"type": "string"
},
@@ -477,6 +489,12 @@
"string",
"null"
]
},
"templateId": {
"type": [
"string",
"null"
]
}
},
"required": [
@@ -977,6 +995,13 @@
"description": "Extra app-server data for a thread.",
"type": "object"
},
"ThreadHistoryMode": {
"enum": [
"legacy",
"paginated"
],
"type": "string"
},
"ThreadId": {
"type": "string"
},
@@ -437,6 +437,18 @@
},
"McpToolCallAppContext": {
"properties": {
"actionName": {
"type": [
"string",
"null"
]
},
"appName": {
"type": [
"string",
"null"
]
},
"connectorId": {
"type": "string"
},
@@ -451,6 +463,12 @@
"string",
"null"
]
},
"templateId": {
"type": [
"string",
"null"
]
}
},
"required": [
@@ -437,6 +437,18 @@
},
"McpToolCallAppContext": {
"properties": {
"actionName": {
"type": [
"string",
"null"
]
},
"appName": {
"type": [
"string",
"null"
]
},
"connectorId": {
"type": "string"
},
@@ -451,6 +463,12 @@
"string",
"null"
]
},
"templateId": {
"type": [
"string",
"null"
]
}
},
"required": [
@@ -437,6 +437,18 @@
},
"McpToolCallAppContext": {
"properties": {
"actionName": {
"type": [
"string",
"null"
]
},
"appName": {
"type": [
"string",
"null"
]
},
"connectorId": {
"type": "string"
},
@@ -451,6 +463,12 @@
"string",
"null"
]
},
"templateId": {
"type": [
"string",
"null"
]
}
},
"required": [
@@ -4,8 +4,9 @@
import type { WebSearchMode } from "../WebSearchMode";
import type { AskForApproval } from "./AskForApproval";
import type { ComputerUseRequirements } from "./ComputerUseRequirements";
import type { ModelsRequirements } from "./ModelsRequirements";
import type { ResidencyRequirement } from "./ResidencyRequirement";
import type { SandboxMode } from "./SandboxMode";
import type { WindowsSandboxSetupMode } from "./WindowsSandboxSetupMode";
export type ConfigRequirements = {allowedApprovalPolicies: Array<AskForApproval> | null, allowedSandboxModes: Array<SandboxMode> | null, allowedWindowsSandboxImplementations: Array<WindowsSandboxSetupMode> | null, allowedPermissionProfiles: { [key in string]?: boolean } | null, defaultPermissions: string | null, allowedWebSearchModes: Array<WebSearchMode> | null, allowManagedHooksOnly: boolean | null, allowAppshots: boolean | null, allowRemoteControl: boolean | null, computerUse: ComputerUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null};
export type ConfigRequirements = {allowedApprovalPolicies: Array<AskForApproval> | null, allowedSandboxModes: Array<SandboxMode> | null, allowedWindowsSandboxImplementations: Array<WindowsSandboxSetupMode> | null, allowedPermissionProfiles: { [key in string]?: boolean } | null, defaultPermissions: string | null, allowedWebSearchModes: Array<WebSearchMode> | null, allowManagedHooksOnly: boolean | null, allowAppshots: boolean | null, allowRemoteControl: boolean | null, computerUse: ComputerUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null, models: ModelsRequirements | null};
@@ -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 McpToolCallAppContext = { connectorId: string, linkId: string | null, resourceUri: string | null, };
export type McpToolCallAppContext = { connectorId: string, linkId: string | null, resourceUri: string | null, appName: string | null, templateId: string | null, actionName: string | null, };
@@ -0,0 +1,6 @@
// 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 { NewThreadModelDefaults } from "./NewThreadModelDefaults";
export type ModelsRequirements = { newThread: NewThreadModelDefaults | null, };
@@ -0,0 +1,6 @@
// 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 { ReasoningEffort } from "../ReasoningEffort";
export type NewThreadModelDefaults = { model: string | null, modelReasoningEffort: ReasoningEffort | null, serviceTier: string | null, };
@@ -3,4 +3,12 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
export type PluginSource = { "type": "local", path: AbsolutePathBuf, } | { "type": "git", url: string, path: string | null, refName: string | null, sha: string | null, } | { "type": "remote" };
export type PluginSource = { "type": "local", path: AbsolutePathBuf, } | { "type": "git", url: string, path: string | null, refName: string | null, sha: string | null, } | { "type": "npm", package: string,
/**
* Optional npm version or version range.
*/
version: string | null,
/**
* Optional HTTPS registry URL. Authentication stays in the user's npm config.
*/
registry: string | null, } | { "type": "remote" };
@@ -18,6 +18,12 @@ import type { ThreadSource } from "./ThreadSource";
* Prefer using thread_id whenever possible.
*/
export type ThreadForkParams = {threadId: string, /**
* Optional last turn id to fork through, inclusive.
*
* When specified, turns after `last_turn_id` are omitted from the fork.
* The referenced turn cannot be in progress.
*/
lastTurnId?: string | null, /**
* Configuration overrides for the forked thread, if any.
*/
model?: string | null, modelProvider?: string | null, serviceTier?: string | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, /**
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ThreadHistoryMode = "legacy" | "paginated";
@@ -267,6 +267,7 @@ export type { ModelServiceTier } from "./ModelServiceTier";
export type { ModelUpgradeInfo } from "./ModelUpgradeInfo";
export type { ModelVerification } from "./ModelVerification";
export type { ModelVerificationNotification } from "./ModelVerificationNotification";
export type { ModelsRequirements } from "./ModelsRequirements";
export type { NetworkAccess } from "./NetworkAccess";
export type { NetworkApprovalContext } from "./NetworkApprovalContext";
export type { NetworkApprovalProtocol } from "./NetworkApprovalProtocol";
@@ -275,6 +276,7 @@ export type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment";
export type { NetworkPolicyRuleAction } from "./NetworkPolicyRuleAction";
export type { NetworkRequirements } from "./NetworkRequirements";
export type { NetworkUnixSocketPermission } from "./NetworkUnixSocketPermission";
export type { NewThreadModelDefaults } from "./NewThreadModelDefaults";
export type { NonSteerableTurnKind } from "./NonSteerableTurnKind";
export type { OverriddenMetadata } from "./OverriddenMetadata";
export type { PatchApplyStatus } from "./PatchApplyStatus";
@@ -410,6 +412,7 @@ export type { ThreadGoalSetParams } from "./ThreadGoalSetParams";
export type { ThreadGoalSetResponse } from "./ThreadGoalSetResponse";
export type { ThreadGoalStatus } from "./ThreadGoalStatus";
export type { ThreadGoalUpdatedNotification } from "./ThreadGoalUpdatedNotification";
export type { ThreadHistoryMode } from "./ThreadHistoryMode";
export type { ThreadInjectItemsParams } from "./ThreadInjectItemsParams";
export type { ThreadInjectItemsResponse } from "./ThreadInjectItemsResponse";
export type { ThreadItem } from "./ThreadItem";
@@ -2580,6 +2580,7 @@ mod tests {
parent_thread_id: None,
preview: "first prompt".to_string(),
ephemeral: true,
history_mode: Default::default(),
model_provider: "openai".to_string(),
created_at: 1,
updated_at: 2,
@@ -2630,6 +2631,7 @@ mod tests {
"parentThreadId": null,
"preview": "first prompt",
"ephemeral": true,
"historyMode": "legacy",
"modelProvider": "openai",
"createdAt": 1,
"updatedAt": 2,
@@ -775,6 +775,9 @@ impl ThreadHistoryBuilder {
connector_id,
link_id: payload.link_id.clone(),
resource_uri: payload.mcp_app_resource_uri.clone(),
app_name: payload.app_name.clone(),
template_id: payload.template_id.clone(),
action_name: payload.action_name.clone(),
}),
mcp_app_resource_uri: payload.mcp_app_resource_uri.clone(),
plugin_id: payload.plugin_id.clone(),
@@ -825,6 +828,9 @@ impl ThreadHistoryBuilder {
connector_id,
link_id: payload.link_id.clone(),
resource_uri: payload.mcp_app_resource_uri.clone(),
app_name: payload.app_name.clone(),
template_id: payload.template_id.clone(),
action_name: payload.action_name.clone(),
}),
mcp_app_resource_uri: payload.mcp_app_resource_uri.clone(),
plugin_id: payload.plugin_id.clone(),
@@ -2418,6 +2424,9 @@ mod tests {
connector_id: None,
mcp_app_resource_uri: None,
link_id: None,
app_name: None,
template_id: None,
action_name: None,
plugin_id: None,
duration: Duration::from_millis(8),
result: Err("boom".into()),
@@ -2499,6 +2508,9 @@ mod tests {
connector_id: Some("calendar".into()),
mcp_app_resource_uri: Some("ui://widget/lookup.html".into()),
link_id: Some("link_calendar".into()),
app_name: Some("Calendar".into()),
template_id: Some("calendar_template".into()),
action_name: Some("lookup".into()),
plugin_id: Some("sample@test".into()),
duration: Duration::from_millis(8),
result: Ok(CallToolResult {
@@ -2533,6 +2545,9 @@ mod tests {
connector_id: "calendar".into(),
link_id: Some("link_calendar".into()),
resource_uri: Some("ui://widget/lookup.html".into()),
app_name: Some("Calendar".into()),
template_id: Some("calendar_template".into()),
action_name: Some("lookup".into()),
}),
mcp_app_resource_uri: Some("ui://widget/lookup.html".into()),
plugin_id: Some("sample@test".into()),
@@ -390,6 +390,23 @@ pub struct ConfigRequirements {
pub enforce_residency: Option<ResidencyRequirement>,
#[experimental("configRequirements/read.network")]
pub network: Option<NetworkRequirements>,
pub models: Option<ModelsRequirements>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ModelsRequirements {
pub new_thread: Option<NewThreadModelDefaults>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct NewThreadModelDefaults {
pub model: Option<String>,
pub model_reasoning_effort: Option<ReasoningEffort>,
pub service_tier: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
@@ -396,6 +396,9 @@ pub struct McpToolCallAppContext {
pub connector_id: String,
pub link_id: Option<String>,
pub resource_uri: Option<String>,
pub app_name: Option<String>,
pub template_id: Option<String>,
pub action_name: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
@@ -895,6 +898,9 @@ impl From<CoreTurnItem> for ThreadItem {
connector_id,
link_id: mcp.link_id,
resource_uri: mcp.mcp_app_resource_uri.clone(),
app_name: mcp.app_name,
template_id: mcp.template_id,
action_name: mcp.action_name,
}),
mcp_app_resource_uri: mcp.mcp_app_resource_uri,
plugin_id: mcp.plugin_id,
@@ -744,6 +744,15 @@ pub enum PluginSource {
ref_name: Option<String>,
sha: Option<String>,
},
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
Npm {
package: String,
/// Optional npm version or version range.
version: Option<String>,
/// Optional HTTPS registry URL. Authentication stays in the user's npm config.
registry: Option<String>,
},
/// The plugin is available in the remote catalog. Download metadata is
/// kept server-side and is not exposed through the app-server API.
Remote,
@@ -175,6 +175,7 @@ fn thread_resume_response_round_trips_initial_turns_page() {
parent_thread_id: None,
preview: String::new(),
ephemeral: false,
history_mode: Default::default(),
model_provider: "openai".to_string(),
created_at: 1,
updated_at: 1,
@@ -838,6 +839,30 @@ fn thread_path_params_deserialize_empty_path_as_none() {
);
}
#[test]
fn thread_fork_last_turn_id_round_trips() {
let params: ThreadForkParams = serde_json::from_value(json!({
"threadId": "thread-1",
"lastTurnId": "turn-2",
}))
.expect("thread/fork params deserialize");
assert_eq!(params.last_turn_id, Some("turn-2".to_string()));
let serialized = serde_json::to_value(params).expect("thread/fork params serialize");
assert_eq!(serialized["lastTurnId"], json!("turn-2"));
let omitted = serde_json::to_value(ThreadForkParams {
thread_id: "thread-1".to_string(),
..Default::default()
})
.expect("thread/fork params without last turn id serialize");
assert_eq!(
omitted["lastTurnId"],
serde_json::Value::Null,
"optional lastTurnId should serialize as null when omitted"
);
}
#[test]
fn fs_get_metadata_response_round_trips_minimal_fields() {
let response = FsGetMetadataResponse {
@@ -1752,6 +1777,7 @@ fn config_requirements_granular_allowed_approval_policy_is_marked_experimental()
hooks: None,
enforce_residency: None,
network: None,
models: None,
});
assert_eq!(reason, Some("askForApproval.granular"));
@@ -2677,6 +2703,9 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
connector_id: Some("calendar".to_string()),
mcp_app_resource_uri: Some("app://connector".to_string()),
link_id: Some("link_calendar".to_string()),
app_name: Some("Calendar".to_string()),
template_id: Some("calendar_template".to_string()),
action_name: Some("create_event".to_string()),
plugin_id: Some("sample@test".to_string()),
status: CoreMcpToolCallStatus::InProgress,
result: None,
@@ -2696,6 +2725,9 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
connector_id: "calendar".to_string(),
link_id: Some("link_calendar".to_string()),
resource_uri: Some("app://connector".to_string()),
app_name: Some("Calendar".to_string()),
template_id: Some("calendar_template".to_string()),
action_name: Some("create_event".to_string()),
}),
mcp_app_resource_uri: Some("app://connector".to_string()),
plugin_id: Some("sample@test".to_string()),
@@ -2713,6 +2745,9 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
connector_id: None,
mcp_app_resource_uri: None,
link_id: None,
app_name: None,
template_id: None,
action_name: None,
plugin_id: None,
status: CoreMcpToolCallStatus::Completed,
result: Some(CallToolResult {
@@ -2759,6 +2794,9 @@ fn mcp_tool_call_app_context_serializes_connector_id() {
connector_id: "calendar".to_string(),
link_id: Some("link_calendar".to_string()),
resource_uri: Some("app://connector".to_string()),
app_name: Some("Calendar".to_string()),
template_id: Some("calendar_template".to_string()),
action_name: Some("create_event".to_string()),
}),
mcp_app_resource_uri: Some("app://connector".to_string()),
plugin_id: None,
@@ -2780,6 +2818,9 @@ fn mcp_tool_call_app_context_serializes_connector_id() {
"connectorId": "calendar",
"linkId": "link_calendar",
"resourceUri": "app://connector",
"appName": "Calendar",
"templateId": "calendar_template",
"actionName": "create_event",
},
"mcpAppResourceUri": "app://connector",
"pluginId": null,
@@ -2790,6 +2831,29 @@ fn mcp_tool_call_app_context_serializes_connector_id() {
);
}
#[test]
fn mcp_tool_call_app_context_serializes_missing_mixed_version_fields_as_null() {
assert_eq!(
serde_json::to_value(McpToolCallAppContext {
connector_id: "calendar".to_string(),
link_id: None,
resource_uri: None,
app_name: None,
template_id: None,
action_name: None,
})
.expect("MCP tool call app context should serialize"),
json!({
"connectorId": "calendar",
"linkId": null,
"resourceUri": null,
"appName": null,
"templateId": null,
"actionName": null,
})
);
}
#[test]
fn user_input_into_core_preserves_image_detail() {
assert_eq!(
@@ -2863,7 +2927,7 @@ fn skills_extra_roots_set_params_rejects_relative_roots() {
}
#[test]
fn plugin_source_serializes_local_git_and_remote_variants() {
fn plugin_source_serializes_local_git_npm_and_remote_variants() {
let local_path = if cfg!(windows) {
r"C:\plugins\linear"
} else {
@@ -2897,6 +2961,21 @@ fn plugin_source_serializes_local_git_and_remote_variants() {
}),
);
assert_eq!(
serde_json::to_value(PluginSource::Npm {
package: "@acme/plugin".to_string(),
version: Some("^1.2.0".to_string()),
registry: Some("https://npm.example.com".to_string()),
})
.unwrap(),
json!({
"type": "npm",
"package": "@acme/plugin",
"version": "^1.2.0",
"registry": "https://npm.example.com",
}),
);
assert_eq!(
serde_json::to_value(PluginSource::Remote).unwrap(),
json!({
@@ -4,6 +4,7 @@ use super::AskForApproval;
use super::SandboxMode;
use super::SandboxPolicy;
use super::Thread;
use super::ThreadHistoryMode;
use super::ThreadItem;
use super::ThreadSource;
use super::Turn;
@@ -105,6 +106,10 @@ pub struct ThreadStartParams {
pub multi_agent_mode: Option<MultiAgentMode>,
#[ts(optional = nullable)]
pub ephemeral: Option<bool>,
/// Persisted thread history contract to use for this new thread.
#[experimental("thread/start.historyMode")]
#[ts(optional = nullable)]
pub history_mode: Option<ThreadHistoryMode>,
#[ts(optional = nullable)]
pub session_start_source: Option<ThreadStartSource>,
/// Optional client-supplied analytics source classification for this thread.
@@ -489,6 +494,13 @@ impl From<ThreadTurnsListResponse> for TurnsPage {
pub struct ThreadForkParams {
pub thread_id: String,
/// Optional last turn id to fork through, inclusive.
///
/// When specified, turns after `last_turn_id` are omitted from the fork.
/// The referenced turn cannot be in progress.
#[ts(optional = nullable)]
pub last_turn_id: Option<String>,
/// [UNSTABLE] Specify the rollout path to fork from.
/// If specified, the thread_id param will be ignored.
#[experimental("thread/fork.path")]
@@ -5,6 +5,7 @@ use super::TurnStatus;
use codex_experimental_api_macros::ExperimentalApi;
use codex_protocol::protocol::SessionSource as CoreSessionSource;
use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource;
use codex_protocol::protocol::ThreadHistoryMode as CoreThreadHistoryMode;
use codex_protocol::protocol::ThreadSource as CoreThreadSource;
use codex_utils_absolute_path::AbsolutePathBuf;
use schemars::JsonSchema;
@@ -64,6 +65,33 @@ impl From<SessionSource> for CoreSessionSource {
}
}
#[derive(Default, Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "lowercase")]
#[ts(rename_all = "lowercase", export_to = "v2/")]
pub enum ThreadHistoryMode {
#[default]
Legacy,
Paginated,
}
impl From<CoreThreadHistoryMode> for ThreadHistoryMode {
fn from(value: CoreThreadHistoryMode) -> Self {
match value {
CoreThreadHistoryMode::Legacy => Self::Legacy,
CoreThreadHistoryMode::Paginated => Self::Paginated,
}
}
}
impl From<ThreadHistoryMode> for CoreThreadHistoryMode {
fn from(value: ThreadHistoryMode) -> Self {
match value {
ThreadHistoryMode::Legacy => Self::Legacy,
ThreadHistoryMode::Paginated => Self::Paginated,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, TS)]
#[serde(try_from = "String", into = "String")]
#[ts(type = "string")]
@@ -155,6 +183,10 @@ pub struct Thread {
pub preview: String,
/// Whether the thread is ephemeral and should not be materialized on disk.
pub ephemeral: bool,
/// Persisted thread history contract selected when this thread was created.
#[experimental("thread.historyMode")]
#[serde(default)]
pub history_mode: ThreadHistoryMode,
/// Model provider used for this thread (for example, 'openai').
pub model_provider: String,
/// Unix timestamp (in seconds) when the thread was created.
+3 -3
View File
@@ -139,7 +139,7 @@ Example with notification opt-out:
- `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. 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 `allowProviderModelFallback` lets providers backed by an authoritative static model catalog replace an unavailable requested `model` with the catalog default; dynamic or cached catalogs preserve the requested model. 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`. Deprecated experimental `multiAgentMode` is ignored; use Ultra reasoning effort for proactive multi-agent behavior. 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 using environment-native absolute paths. Skills found below those roots are listed and read through the owning environment. Stdio MCP servers declared by selected plugins are started in that environment, and HTTP MCP connections use that environment's HTTP client.
- `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. 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/fork` — fork an existing thread into a new thread id by copying the stored history; pass an optional `lastTurnId` to copy history only through that turn, inclusive, and drop later turns from the fork. An in-progress `lastTurnId` is rejected. If `lastTurnId` is null while the source thread is 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. 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. `instructionSources` lists loaded instruction files using each source environment's native absolute path syntax, including files loaded from remote environments. 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. Their deprecated experimental `multiAgentMode` field, and the corresponding thread setting, always report `explicitRequestOnly`; Ultra reasoning effort is the source of proactive multi-agent behavior.
- `thread/list` — page through stored threads; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Experimental clients can use `parentThreadId` for direct spawned children or `ancestorThreadId` for spawned descendants at any depth; the two filters are mutually exclusive. Review and Guardian threads are not included because they do not participate in that spawn-edge lifecycle. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. Subagent threads also include `parentThreadId` when the immediate parent is known.
- `thread/loaded/list` — list the thread ids currently loaded in memory.
@@ -242,7 +242,7 @@ Example with notification opt-out:
- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any `details` returned by detect. Callers may pass `source` to identify the product that initiated the import; omitted or `null` means unspecified. The response acknowledges the synchronous import phase with an `importId`. Expected migration failures are reported as per-item failures rather than JSON-RPC errors, so the server still returns that `importId` and emits `externalAgentConfig/import/completed` with the same ID once all synchronous and background work finishes. The completion notification contains type-level `itemTypeResults` with successes and failures, including raw failure messages for the client to report separately.
- `config/value/write` — write a single config key/value to the user's config.toml on disk; dotted paths such as `desktop.someKey` use the same generic write surface.
- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads, including multiple `desktop.*` edits.
- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), the layered permission-profile allow map (`allowedPermissionProfiles`), the managed permission-profile default (`defaultPermissions`), lifecycle hook lockdown (`allowManagedHooksOnly`), remote-control policy (`allowRemoteControl`; `false` force-disables remote control while `true` or `null` preserves existing behavior), computer use policy (`computerUse`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`.
- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), the layered permission-profile allow map (`allowedPermissionProfiles`), the managed permission-profile default (`defaultPermissions`), lifecycle hook lockdown (`allowManagedHooksOnly`), remote-control policy (`allowRemoteControl`; `false` force-disables remote control while `true` or `null` preserves existing behavior), computer use policy (`computerUse`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, managed new-thread defaults (`models.newThread.model`, `models.newThread.modelReasoningEffort`, and `models.newThread.serviceTier`), and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`.
### Example: Start or resume a thread
@@ -1372,7 +1372,7 @@ Today both notifications carry an empty `items` array even when item events were
- `reasoning` — `{id, summary, content}` where `summary` holds streamed reasoning summaries (applicable for most OpenAI models) and `content` holds raw reasoning blocks (applicable for e.g. open source models).
- `commandExecution` — `{id, command, cwd, status, commandActions, aggregatedOutput?, exitCode?, durationMs?}` for sandboxed commands; `status` is `inProgress`, `completed`, `failed`, or `declined`.
- `fileChange` — `{id, changes, status}` describing proposed edits; `changes` list `{path, kind, diff}` and `status` is `inProgress`, `completed`, `failed`, or `declined`.
- `mcpToolCall` — `{id, server, tool, status, arguments, appContext, mcpAppResourceUri?, pluginId, result?, error?}` describing MCP calls; `appContext` is `{connectorId, linkId, resourceUri}` for calls through a trusted MCP app, where `connectorId` identifies the connector that owns the tool, `linkId` identifies the app link, and `resourceUri` points to the widget template. The top-level `mcpAppResourceUri` is deprecated and temporarily duplicated for client migration. `tool` identifies the invoked action. `status` is `inProgress`, `completed`, or `failed`.
- `mcpToolCall` — `{id, server, tool, status, arguments, appContext, mcpAppResourceUri?, pluginId, result?, error?}` describing MCP calls; `appContext` is `{connectorId, linkId, resourceUri, appName, templateId, actionName}` for calls through a trusted MCP app, where `connectorId` identifies the connector that owns the tool, `linkId` identifies the app link, `resourceUri` points to the widget template, `appName` is the connector's display name, `templateId` identifies the app template, and `actionName` is the stable connector `Action.name`. `appName`, `templateId`, and `actionName` may be null for older rollout entries. The top-level `mcpAppResourceUri` is deprecated and temporarily duplicated for client migration. `tool` identifies the raw MCP tool. `status` is `inProgress`, `completed`, or `failed`.
- `collabToolCall` — `{id, tool, status, senderThreadId, receiverThreadId?, newThreadId?, prompt?, agentStatus?}` describing collab tool calls (`spawn_agent`, `send_input`, `resume_agent`, `wait`, `close_agent`); `status` is `inProgress`, `completed`, or `failed`.
- `webSearch` — `{id, query, action?}` for a web search request issued by the agent; `action` mirrors the Responses API web_search action payload (`search`, `open_page`, `find_in_page`) and may be omitted until completion.
- `imageView` — `{id, path}` emitted when the agent invokes the image viewer tool.
@@ -2287,6 +2287,7 @@ mod tests {
cwd: test_path_buf("/tmp").abs().into(),
cli_version: "0.0.0".to_string(),
source: SessionSource::Cli,
history_mode: Default::default(),
thread_source: None,
agent_nickname: None,
agent_role: None,
+13 -8
View File
@@ -912,7 +912,7 @@ pub async fn run_main_with_transport_options(
async move {
let mut listen_for_threads = true;
let mut shutdown_state = ShutdownState::default();
loop {
let exit_reason = loop {
let running_turn_count = {
let running_turn_count = running_turn_count_rx.borrow();
*running_turn_count
@@ -925,7 +925,7 @@ pub async fn run_main_with_transport_options(
let _ = outbound_control_tx
.send(OutboundControlEvent::DisconnectAll)
.await;
break;
break "shutdown_requested";
}
tokio::select! {
@@ -947,7 +947,7 @@ pub async fn run_main_with_transport_options(
}
event = transport_event_rx.recv() => {
let Some(event) = event else {
break;
break "transport_channel_closed";
};
match event {
TransportEvent::ConnectionOpened {
@@ -977,7 +977,7 @@ pub async fn run_main_with_transport_options(
.await
.is_err()
{
break;
break "outbound_router_closed";
}
connections.insert(
connection_id,
@@ -1005,10 +1005,10 @@ pub async fn run_main_with_transport_options(
.await;
});
if !outbound_closed {
break;
break "outbound_router_closed";
}
if shutdown_when_no_connections && connections.is_empty() {
break;
break "last_connection_closed";
}
}
TransportEvent::IncomingMessage { connection_id, message } => {
@@ -1147,7 +1147,7 @@ pub async fn run_main_with_transport_options(
}
}
}
}
};
if !shutdown_state.forced() {
futures::future::join_all(
@@ -1162,7 +1162,12 @@ pub async fn run_main_with_transport_options(
} else {
connection_cleanup_tasks.abort();
}
info!("processor task exited (channel closed)");
info!(
exit_reason,
remaining_connection_count = connections.len(),
shutdown_forced = shutdown_state.forced(),
"processor task exited"
);
}
});
+42 -6
View File
@@ -504,13 +504,36 @@ impl OutgoingMessageSender {
where
T: Into<ClientResponsePayload>,
{
self.send_response_as(request_id, response.into()).await;
self.send_response_as_inner(request_id, response.into(), /*thread_originator*/ None)
.await;
}
pub(crate) async fn send_response_with_thread_originator<T>(
&self,
request_id: ConnectionRequestId,
response: T,
thread_originator: String,
) where
T: Into<ClientResponsePayload>,
{
self.send_response_as_inner(request_id, response.into(), Some(thread_originator))
.await;
}
pub(crate) async fn send_response_as(
&self,
request_id: ConnectionRequestId,
response: ClientResponsePayload,
) {
self.send_response_as_inner(request_id, response, /*thread_originator*/ None)
.await;
}
async fn send_response_as_inner(
&self,
request_id: ConnectionRequestId,
response: ClientResponsePayload,
thread_originator: Option<String>,
) {
let connection_id = request_id.connection_id;
let request_id_for_analytics = request_id.request_id.clone();
@@ -518,11 +541,24 @@ impl OutgoingMessageSender {
.into_jsonrpc_parts_and_payload(request_id.request_id.clone())
.map(|(id, result, response)| {
if let Some(response) = response {
self.analytics_events_client.track_response(
connection_id.0,
request_id_for_analytics,
response,
);
match thread_originator {
Some(thread_originator) => {
self.analytics_events_client
.track_response_with_thread_originator(
connection_id.0,
request_id_for_analytics,
response,
thread_originator,
);
}
None => {
self.analytics_events_client.track_response(
connection_id.0,
request_id_for_analytics,
response,
);
}
}
}
(id, result)
});
@@ -206,6 +206,8 @@ use codex_app_server_protocol::ThreadGoalSetResponse;
use codex_app_server_protocol::ThreadGoalStatus;
use codex_app_server_protocol::ThreadGoalUpdatedNotification;
use codex_app_server_protocol::ThreadHistoryBuilder;
#[cfg(test)]
use codex_app_server_protocol::ThreadHistoryMode;
use codex_app_server_protocol::ThreadIncrementElicitationParams;
use codex_app_server_protocol::ThreadIncrementElicitationResponse;
use codex_app_server_protocol::ThreadInjectItemsParams;
@@ -328,6 +330,7 @@ use codex_core::path_utils;
#[cfg(test)]
use codex_core::read_head_for_summary;
use codex_core::sandboxing::SandboxPermissions;
use codex_core::truncate_rollout_after_turn_id;
use codex_core::windows_sandbox::WindowsSandboxLevelExt;
use codex_core::windows_sandbox::WindowsSandboxSetupMode as CoreWindowsSandboxSetupMode;
use codex_core::windows_sandbox::WindowsSandboxSetupRequest;
@@ -24,9 +24,11 @@ use codex_app_server_protocol::ExperimentalFeatureEnablementSetResponse;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::ManagedHooksRequirements;
use codex_app_server_protocol::ModelProviderCapabilitiesReadResponse;
use codex_app_server_protocol::ModelsRequirements;
use codex_app_server_protocol::NetworkDomainPermission;
use codex_app_server_protocol::NetworkRequirements;
use codex_app_server_protocol::NetworkUnixSocketPermission;
use codex_app_server_protocol::NewThreadModelDefaults;
use codex_app_server_protocol::SandboxMode;
use codex_app_server_protocol::WindowsSandboxSetupMode;
use codex_config::ConfigRequirementsToml;
@@ -375,6 +377,13 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR
.enforce_residency
.map(map_residency_requirement_to_api),
network: requirements.network.map(map_network_requirements_to_api),
models: requirements.models.map(|models| ModelsRequirements {
new_thread: models.new_thread.map(|new_thread| NewThreadModelDefaults {
model: new_thread.model,
model_reasoning_effort: new_thread.model_reasoning_effort,
service_tier: new_thread.service_tier,
}),
}),
}
}
@@ -568,7 +577,10 @@ mod tests {
use codex_app_server_protocol::WindowsSandboxSetupMode;
use codex_config::ComputerUseRequirementsToml;
use codex_config::ConfigRequirementsToml;
use codex_config::ModelsRequirementsToml;
use codex_config::NewThreadModelDefaultsToml;
use codex_config::WindowsRequirementsToml;
use codex_protocol::openai_models::ReasoningEffort;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
@@ -628,6 +640,31 @@ mod tests {
assert_eq!(mapped.allow_remote_control, Some(false));
}
#[test]
fn requirements_api_includes_new_thread_model_defaults() {
let mapped = map_requirements_toml_to_api(ConfigRequirementsToml {
models: Some(ModelsRequirementsToml {
new_thread: Some(NewThreadModelDefaultsToml {
model: Some("gpt-managed".to_string()),
model_reasoning_effort: Some(ReasoningEffort::Medium),
service_tier: Some("fast".to_string()),
}),
}),
..ConfigRequirementsToml::default()
});
let defaults = mapped
.models
.and_then(|models| models.new_thread)
.expect("new-thread defaults");
assert_eq!(defaults.model.as_deref(), Some("gpt-managed"));
assert_eq!(
defaults.model_reasoning_effort,
Some(ReasoningEffort::Medium)
);
assert_eq!(defaults.service_tier.as_deref(), Some("fast"));
}
#[test]
fn requirements_api_includes_computer_use_requirements() {
let mapped = map_requirements_toml_to_api(ConfigRequirementsToml {
@@ -15,6 +15,7 @@ use codex_models_manager::manager::RefreshStrategy;
use codex_protocol::ThreadId;
use codex_protocol::models::BaseInstructions;
use codex_protocol::protocol::MultiAgentVersion;
use codex_protocol::protocol::ThreadHistoryMode;
use codex_protocol::protocol::ThreadMemoryMode;
use codex_rollout::is_persisted_rollout_item;
use codex_thread_store::AppendThreadItemsParams;
@@ -221,6 +222,7 @@ impl ExternalAgentSessionImporter {
dynamic_tools: Vec::new(),
selected_capability_roots: Vec::new(),
multi_agent_version: Some(MultiAgentVersion::V1),
history_mode: ThreadHistoryMode::Legacy,
initial_window_id: uuid::Uuid::now_v7().to_string(),
metadata: ThreadPersistenceMetadata {
cwd: Some(cwd.clone()),
@@ -121,17 +121,11 @@ impl McpRequestProcessor {
} = params;
let auth = self.auth_manager.auth().await;
let (config, mcp_config) = match thread_id.as_deref() {
let (mcp_config, runtime_context) = match thread_id.as_deref() {
Some(thread_id) => {
let (_, thread) = self.load_thread(thread_id).await?;
let thread_config = thread.config().await;
let config = self
.config_manager
.load_latest_config_for_thread(thread_config.as_ref())
.await
.map_err(|err| internal_error(format!("failed to reload config: {err}")))?;
let mcp_config = thread.runtime_mcp_config(&config).await;
(config, mcp_config)
let runtime = thread.current_mcp_runtime().await;
(runtime.config().clone(), runtime.runtime_context().clone())
}
None => {
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
@@ -140,7 +134,11 @@ impl McpRequestProcessor {
.mcp_manager()
.runtime_config(&config)
.await;
(config, mcp_config)
let runtime_context = McpRuntimeContext::new(
self.thread_manager.environment_manager(),
config.cwd.to_path_buf(),
);
(mcp_config, runtime_context)
}
};
let effective_servers = codex_mcp::effective_mcp_servers(&mcp_config, auth.as_ref());
@@ -167,10 +165,6 @@ impl McpRequestProcessor {
}
};
let runtime_context = McpRuntimeContext::new(
self.thread_manager.environment_manager(),
config.cwd.to_path_buf(),
);
let http_client = runtime_context
.resolve_http_client(&name, server)
.map_err(|err| {
@@ -189,16 +183,16 @@ impl McpRequestProcessor {
let handle = perform_oauth_login_return_url_with_http_client(
&name,
&url,
config.mcp_oauth_credentials_store_mode,
config.auth_keyring_backend_kind(),
mcp_config.mcp_oauth_credentials_store_mode,
mcp_config.auth_keyring_backend_kind,
http_headers,
env_http_headers,
&resolved_scopes.scopes,
server.oauth_client_id(),
server.oauth_resource.as_deref(),
timeout_secs,
config.mcp_oauth_callback_port,
config.mcp_oauth_callback_url.as_deref(),
mcp_config.mcp_oauth_callback_port,
mcp_config.mcp_oauth_callback_url.as_deref(),
http_client,
)
.await
@@ -250,18 +244,23 @@ impl McpRequestProcessor {
None => (self.load_latest_config(/*fallback_cwd*/ None).await?, None),
};
let mcp_manager = self.thread_manager.mcp_manager();
let mcp_config = match thread {
Some(thread) => thread.runtime_mcp_config(&config).await,
None => mcp_manager.runtime_config(&config).await,
};
let codex_apps_tools_cache = mcp_manager.codex_apps_tools_cache();
let auth = self.auth_manager.auth().await;
let environment_manager = self.thread_manager.environment_manager();
// This status path has no turn-selected environment. Use config cwd
// as the local stdio fallback; named environment stdio MCPs must
// declare their own absolute cwd.
let runtime_context =
McpRuntimeContext::new(Arc::clone(&environment_manager), config.cwd.to_path_buf());
let (mcp_config, runtime_context) = match thread {
Some(thread) => {
let mcp_config = thread.runtime_mcp_config(&config).await;
let runtime = thread.current_mcp_runtime().await;
(mcp_config, runtime.runtime_context().clone())
}
None => {
let mcp_config = mcp_manager.runtime_config(&config).await;
let runtime_context = McpRuntimeContext::new(
self.thread_manager.environment_manager(),
config.cwd.to_path_buf(),
);
(mcp_config, runtime_context)
}
};
tokio::spawn(async move {
Self::list_mcp_server_status_task(
@@ -101,6 +101,15 @@ fn marketplace_plugin_source_to_info(source: MarketplacePluginSource) -> PluginS
ref_name,
sha,
},
MarketplacePluginSource::Npm {
package,
version,
registry,
} => PluginSource::Npm {
package,
version,
registry,
},
}
}
@@ -134,7 +143,7 @@ fn share_context_for_source(
creator_name: None,
share_principals: None,
}),
MarketplacePluginSource::Git { .. } => None,
MarketplacePluginSource::Git { .. } | MarketplacePluginSource::Npm { .. } => None,
}
}
@@ -640,6 +640,7 @@ pub(super) async fn handle_pending_thread_resume_request(
active_permission_profile,
workspace_roots,
reasoning_effort,
originator,
..
} = config_snapshot;
let instruction_sources = pending.instruction_sources;
@@ -665,7 +666,9 @@ pub(super) async fn handle_pending_thread_resume_request(
multi_agent_mode: MultiAgentMode::ExplicitRequestOnly,
initial_turns_page,
};
outgoing.send_response(request_id, response).await;
outgoing
.send_response_with_thread_originator(request_id, response, originator)
.await;
// Match cold resume: metadata-only resume should attach the listener without
// paying the cost of turn reconstruction for historical usage replay.
if let Some(token_usage_thread) = token_usage_thread {
@@ -5,6 +5,7 @@ use codex_extension_api::ExtensionDataInit;
use codex_protocol::config_types::MultiAgentMode;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
use codex_protocol::protocol::ThreadHistoryMode;
const THREAD_LIST_DEFAULT_LIMIT: usize = 25;
const THREAD_LIST_MAX_LIMIT: usize = 100;
@@ -926,6 +927,7 @@ impl ThreadRequestProcessor {
personality,
multi_agent_mode: _multi_agent_mode,
ephemeral,
history_mode,
session_start_source,
thread_source,
environments,
@@ -980,6 +982,7 @@ impl ThreadRequestProcessor {
typesafe_overrides,
dynamic_tools,
selected_capability_roots.unwrap_or_default(),
history_mode.map(Into::into),
session_start_source,
thread_source.map(Into::into),
environment_selections,
@@ -1055,6 +1058,7 @@ impl ThreadRequestProcessor {
typesafe_overrides: ConfigOverrides,
dynamic_tools: Option<Vec<DynamicToolSpec>>,
selected_capability_roots: Vec<SelectedCapabilityRoot>,
history_mode: Option<ThreadHistoryMode>,
session_start_source: Option<codex_app_server_protocol::ThreadStartSource>,
thread_source: Option<codex_protocol::protocol::ThreadSource>,
environments: Option<Vec<TurnEnvironmentSelection>>,
@@ -1155,7 +1159,6 @@ impl ThreadRequestProcessor {
let mut thread_extension_init = ExtensionDataInit::new();
if !selected_capability_roots.is_empty() {
thread_extension_init.insert(selected_capability_roots);
codex_mcp_extension::initialize_executor_plugin_thread_data(&mut thread_extension_init);
}
let create_thread_started_at = std::time::Instant::now();
let NewThread {
@@ -1174,6 +1177,7 @@ impl ThreadRequestProcessor {
codex_app_server_protocol::ThreadStartSource::Startup => InitialHistory::New,
codex_app_server_protocol::ThreadStartSource::Clear => InitialHistory::Cleared,
},
history_mode,
session_source: None,
thread_source,
dynamic_tools,
@@ -1191,6 +1195,7 @@ impl ThreadRequestProcessor {
.await
.map_err(|err| match err {
CodexErr::InvalidRequest(message) => invalid_request(message),
CodexErr::UnsupportedOperation(message) => method_not_found(message),
err => internal_error(format!("error creating thread: {err}")),
})?;
let session_telemetry = thread.session_telemetry();
@@ -1269,6 +1274,7 @@ impl ThreadRequestProcessor {
let cwd = config_snapshot.cwd().clone();
let active_permission_profile =
thread_response_active_permission_profile(config_snapshot.active_permission_profile);
let thread_originator = config_snapshot.originator.clone();
let response = ThreadStartResponse {
thread: thread.clone(),
@@ -1288,7 +1294,7 @@ impl ThreadRequestProcessor {
let notif = thread_started_notification(thread);
listener_task_context
.outgoing
.send_response(request_id, response)
.send_response_with_thread_originator(request_id, response, thread_originator)
.instrument(tracing::info_span!(
"app_server.thread_start.send_response",
otel.name = "app_server.thread_start.send_response",
@@ -2299,6 +2305,9 @@ impl ThreadRequestProcessor {
Err(ThreadStoreError::InvalidRequest { message }) => {
Err(ThreadReadViewError::InvalidRequest(message))
}
Err(ThreadStoreError::Unsupported { operation }) => {
Err(ThreadReadViewError::Unsupported(operation))
}
Err(err) => Err(ThreadReadViewError::Internal(format!(
"failed to read thread: {err}"
))),
@@ -2501,6 +2510,9 @@ impl ThreadRequestProcessor {
Err(ThreadStoreError::InvalidRequest { message }) => {
return Err(ThreadReadViewError::InvalidRequest(message));
}
Err(ThreadStoreError::Unsupported { operation }) => {
return Err(ThreadReadViewError::Unsupported(operation));
}
Err(err) => {
return Err(ThreadReadViewError::Internal(format!(
"failed to read thread: {err}"
@@ -2864,6 +2876,7 @@ impl ThreadRequestProcessor {
}
}
let thread_originator = config_snapshot.originator.clone();
let response = ThreadResumeResponse {
thread,
model: session_configured.model,
@@ -2882,7 +2895,9 @@ impl ThreadRequestProcessor {
};
let connection_id = request_id.connection_id;
self.outgoing.send_response(request_id, response).await;
self.outgoing
.send_response_with_thread_originator(request_id, response, thread_originator)
.await;
// `excludeTurns` is explicitly the cheap resume path, so avoid
// rebuilding history only to attribute a replayed usage update.
if let Some(token_usage_thread) = token_usage_thread {
@@ -3372,6 +3387,7 @@ impl ThreadRequestProcessor {
) -> Result<(), JSONRPCErrorError> {
let ThreadForkParams {
thread_id,
last_turn_id,
path,
model,
model_provider,
@@ -3406,12 +3422,20 @@ impl ThreadRequestProcessor {
let history_items = source_thread
.history
.take()
.map(|history| Arc::new(history.items))
.map(|history| history.items)
.ok_or_else(|| {
internal_error(format!(
"thread {source_thread_id} did not include persisted history"
))
})?;
let history_items = if let Some(last_turn_id) = last_turn_id.as_deref() {
Arc::new(
truncate_rollout_after_turn_id(&history_items, last_turn_id)
.map_err(|err| core_thread_write_error("truncate thread for fork", err))?,
)
} else {
Arc::new(history_items)
};
let history_cwd = Some(source_thread.cwd.clone());
// Persist Windows sandbox mode.
@@ -3583,6 +3607,7 @@ impl ThreadRequestProcessor {
);
let active_permission_profile =
thread_response_active_permission_profile(config_snapshot.active_permission_profile);
let thread_originator = config_snapshot.originator.clone();
let response = ThreadForkResponse {
thread: thread.clone(),
@@ -3603,7 +3628,9 @@ impl ThreadRequestProcessor {
let notif = thread_started_notification(thread);
let connection_id = request_id.connection_id;
let token_usage_thread = include_turns.then(|| response.thread.clone());
self.outgoing.send_response(request_id, response).await;
self.outgoing
.send_response_with_thread_originator(request_id, response, thread_originator)
.await;
// `excludeTurns` is the cheap fork path, so skip restored usage replay
// instead of rebuilding history only to attribute a historical update.
if let Some(token_usage_thread) = token_usage_thread {
@@ -4248,6 +4275,7 @@ pub(crate) fn thread_from_stored_thread(
parent_thread_id: thread.parent_thread_id.map(|id| id.to_string()),
preview: thread.preview,
ephemeral: false,
history_mode: thread.history_mode.into(),
model_provider: if thread.model_provider.is_empty() {
fallback_provider.to_string()
} else {
@@ -4459,6 +4487,7 @@ fn build_thread_from_snapshot(
parent_thread_id: config_snapshot.parent_thread_id.map(|id| id.to_string()),
preview: String::new(),
ephemeral: config_snapshot.ephemeral,
history_mode: config_snapshot.history_mode.into(),
model_provider: config_snapshot.model_provider_id.clone(),
created_at: now,
updated_at: now,
@@ -483,6 +483,7 @@ mod thread_processor_behavior_tests {
cwd: PathBuf::from("/tmp"),
cli_version: "0.0.0".to_string(),
source: SessionSource::Cli,
history_mode: Default::default(),
thread_source: Some(codex_protocol::protocol::ThreadSource::User),
agent_nickname: None,
agent_role: None,
@@ -777,6 +778,7 @@ mod thread_processor_behavior_tests {
},
},
session_source: SessionSource::Cli,
history_mode: Default::default(),
forked_from_thread_id: None,
parent_thread_id: None,
thread_source: None,
@@ -83,6 +83,9 @@ mod tests {
connector_id: "calendar".to_string(),
link_id: Some("link_calendar".to_string()),
resource_uri: Some("ui://widget/lookup.html".to_string()),
app_name: Some("Calendar".to_string()),
template_id: Some("calendar_template".to_string()),
action_name: Some("lookup".to_string()),
}),
mcp_app_resource_uri: Some("ui://widget/lookup.html".to_string()),
plugin_id: Some("sample@test".to_string()),
@@ -130,6 +133,9 @@ mod tests {
connector_id: "calendar".to_string(),
link_id: Some("link_calendar".to_string()),
resource_uri: Some("ui://widget/lookup.html".to_string()),
app_name: Some("Calendar".to_string()),
template_id: Some("calendar_template".to_string()),
action_name: Some("lookup".to_string()),
}),
mcp_app_resource_uri: Some("ui://widget/lookup.html".to_string()),
plugin_id: Some("sample@test".to_string()),
@@ -189,6 +195,7 @@ mod tests {
parent_thread_id: None,
preview: "preview".to_string(),
ephemeral: false,
history_mode: Default::default(),
model_provider: "mock_provider".to_string(),
created_at: 0,
updated_at: 0,
@@ -320,6 +320,7 @@ pub(crate) fn summary_to_thread(
parent_thread_id: None,
preview,
ephemeral: false,
history_mode: ThreadHistoryMode::Legacy,
model_provider,
created_at: created_at.map(|dt| dt.timestamp()).unwrap_or(0),
updated_at: updated_at.map(|dt| dt.timestamp()).unwrap_or(0),
+1
View File
@@ -895,6 +895,7 @@ mod tests {
parent_thread_id: None,
preview: String::new(),
ephemeral: false,
history_mode: Default::default(),
model_provider: "mock-provider".to_string(),
created_at: 0,
updated_at: 0,
@@ -0,0 +1,55 @@
use std::path::Path;
use std::process::Command;
use std::process::Stdio;
use anyhow::Context;
use anyhow::Result;
use serde_json::Value;
use serde_json::json;
pub fn app_server_json_shutdown_event(
binary: &str,
args: &[&str],
codex_home: &Path,
) -> Result<Value> {
std::fs::write(
codex_home.join("config.toml"),
"[features]\nplugins = false\n",
)?;
let output = Command::new(codex_utils_cargo_bin::cargo_bin(binary)?)
.stdin(Stdio::null())
.env("CODEX_HOME", codex_home)
.env(
"CODEX_APP_SERVER_MANAGED_CONFIG_PATH",
codex_home.join("managed_config.toml"),
)
.env("LOG_FORMAT", "json")
.env("RUST_LOG", "codex_app_server=info")
.args(args)
.output()?;
let stderr = String::from_utf8(output.stderr)?;
anyhow::ensure!(output.status.success(), "app-server failed: {stderr}");
let events = stderr
.lines()
.filter(|line| !line.is_empty())
.map(serde_json::from_str::<Value>)
.collect::<serde_json::Result<Vec<_>>>()
.with_context(|| format!("app-server stderr was not JSONL: {stderr}"))?;
let event = events
.iter()
.find(|event| event["fields"]["message"] == "processor task exited")
.context("missing INFO shutdown event in app-server JSON logs")?;
let timestamp = event["timestamp"]
.as_str()
.context("shutdown event did not include a timestamp")?;
chrono::DateTime::parse_from_rfc3339(timestamp)
.with_context(|| format!("shutdown event timestamp was not RFC 3339: {timestamp}"))?;
Ok(json!({
"level": event["level"],
"fields": event["fields"],
"target": event["target"],
}))
}
+2
View File
@@ -3,6 +3,7 @@
mod analytics_server;
mod auth_fixtures;
mod config;
mod json_logging;
mod mock_model_server;
mod models_cache;
mod responses;
@@ -26,6 +27,7 @@ pub use core_test_support::test_absolute_path;
pub use core_test_support::test_path_buf_with_windows;
pub use core_test_support::test_tmp_path;
pub use core_test_support::test_tmp_path_buf;
pub use json_logging::app_server_json_shutdown_event;
pub use mock_model_server::create_mock_responses_server_repeating_assistant;
pub use mock_model_server::create_mock_responses_server_sequence;
pub use mock_model_server::create_mock_responses_server_sequence_unchecked;
@@ -202,6 +202,7 @@ fn create_fake_rollout_with_source_and_parent_thread_id(
dynamic_tools: None,
selected_capability_roots: Vec::new(),
memory_mode: None,
history_mode: Default::default(),
multi_agent_version: None,
context_window: None,
};
@@ -291,6 +292,7 @@ pub fn create_fake_rollout_with_text_elements(
dynamic_tools: None,
selected_capability_roots: Vec::new(),
memory_mode: None,
history_mode: Default::default(),
multi_agent_version: None,
context_window: None,
};
@@ -133,6 +133,7 @@ async fn get_conversation_summary_by_thread_id_reads_pathless_store_thread() ->
dynamic_tools: Vec::new(),
selected_capability_roots: Vec::new(),
multi_agent_version: None,
history_mode: Default::default(),
initial_window_id: Uuid::now_v7().to_string(),
metadata: ThreadPersistenceMetadata {
cwd: None,
@@ -0,0 +1,27 @@
use anyhow::Result;
use app_test_support::app_server_json_shutdown_event;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::TempDir;
#[test]
fn standalone_app_server_emits_json_info_events() -> Result<()> {
let codex_home = TempDir::new()?;
let event = app_server_json_shutdown_event("codex-app-server", &[], codex_home.path())?;
assert_eq!(
event,
json!({
"level": "INFO",
"fields": {
"message": "processor task exited",
"exit_reason": "last_connection_closed",
"remaining_connection_count": 0,
"shutdown_forced": false,
},
"target": "codex_app_server",
})
);
Ok(())
}
+1
View File
@@ -1,5 +1,6 @@
mod auth;
mod conversation_summary;
mod fuzzy_file_search;
mod logging;
mod strict_config;
mod v2;
@@ -145,7 +145,7 @@ pub(crate) async fn wait_for_goal_event(
.await
}
async fn wait_for_matching_analytics_event(
pub(crate) async fn wait_for_matching_analytics_event(
server: &MockServer,
read_timeout: Duration,
matches: impl Fn(&Value) -> bool,
@@ -191,6 +191,7 @@ pub(crate) fn assert_basic_thread_initialized_event(
event: &Value,
thread_id: &str,
session_id: &str,
expected_product_client_id: &str,
expected_model: &str,
initialization_mode: &str,
expected_thread_source: &str,
@@ -199,7 +200,7 @@ pub(crate) fn assert_basic_thread_initialized_event(
assert_eq!(event["event_params"]["session_id"], session_id);
assert_eq!(
event["event_params"]["app_server_client"]["product_client_id"],
DEFAULT_CLIENT_NAME
expected_product_client_id
);
assert_eq!(
event["event_params"]["app_server_client"]["client_name"],
@@ -1533,7 +1533,7 @@ impl ServerHandler for AppListMcpServer {
}
}
async fn start_apps_server_with_delays(
pub(super) async fn start_apps_server_with_delays(
connectors: Vec<AppInfo>,
tools: Vec<Tool>,
directory_delay: Duration,
@@ -1693,7 +1693,7 @@ async fn list_directory_connectors(
}
}
fn connector_tool(connector_id: &str, connector_name: &str) -> Result<Tool> {
pub(super) fn connector_tool(connector_id: &str, connector_name: &str) -> Result<Tool> {
let schema: JsonObject = serde_json::from_value(json!({
"type": "object",
"additionalProperties": false
@@ -75,6 +75,43 @@ async fn config_requirements_read_includes_allow_remote_control() -> Result<()>
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_requirements_read_includes_new_thread_model_defaults() -> Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join("requirements.toml"),
r#"
[models.new_thread]
model = "gpt-managed"
model_reasoning_effort = "medium"
service_tier = "fast"
"#,
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp.send_config_requirements_read_request().await?;
let response = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: ConfigRequirementsReadResponse = to_response(response)?;
let defaults = response
.requirements
.and_then(|requirements| requirements.models)
.and_then(|models| models.new_thread)
.expect("managed new-thread defaults");
assert_eq!(defaults.model.as_deref(), Some("gpt-managed"));
assert_eq!(
defaults.model_reasoning_effort,
Some(ReasoningEffort::Medium)
);
assert_eq!(defaults.service_tier.as_deref(), Some("fast"));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_read_returns_effective_and_layers() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -191,7 +191,6 @@ HTTP_PROXY = {http_proxy}
)
.await?;
std::fs::write(plugin.path().join(".mcp.json"), r#"{"mcpServers":{}}"#)?;
let config_path = codex_home.path().join("config.toml");
let mut config = std::fs::read_to_string(&config_path)?;
config.push_str(&format!(
@@ -10,6 +10,8 @@ use app_test_support::create_mock_responses_server_sequence;
use app_test_support::to_response;
use app_test_support::write_mock_responses_config_toml;
use axum::Router;
use codex_app_server_protocol::CapabilityRootLocation;
use codex_app_server_protocol::EnvironmentAddResponse;
use codex_app_server_protocol::ItemCompletedNotification;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCResponse;
@@ -22,15 +24,21 @@ use codex_app_server_protocol::McpServerToolCallParams;
use codex_app_server_protocol::McpServerToolCallResponse;
use codex_app_server_protocol::McpToolCallStatus;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SelectedCapabilityRoot;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnEnvironmentParams;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::UserInput as V2UserInput;
use codex_features::Feature;
use codex_utils_path_uri::PathUri;
use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP;
use core_test_support::responses;
use futures::SinkExt;
use futures::StreamExt;
use pretty_assertions::assert_eq;
use rmcp::handler::server::ServerHandler;
use rmcp::model::BooleanSchema;
@@ -56,8 +64,13 @@ use rmcp::transport::streamable_http_server::session::local::LocalSessionManager
use serde_json::json;
use tempfile::TempDir;
use tokio::net::TcpListener;
use tokio::net::TcpStream;
use tokio::sync::oneshot;
use tokio::task::JoinHandle;
use tokio::time::timeout;
use tokio_tungstenite::WebSocketStream;
use tokio_tungstenite::accept_async;
use tokio_tungstenite::tungstenite::Message;
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);
const TEST_SERVER_NAME: &str = "tool_server";
@@ -68,6 +81,7 @@ const ELICITATION_MESSAGE: &str = "Allow this request?";
const URL_ELICITATION_TRIGGER_MESSAGE: &str = "auth";
const URL_ELICITATION_MESSAGE: &str = "Sign in to GitHub to continue.";
const URL_ELICITATION_URL: &str = "https://github.example/login/device";
const LATE_ENVIRONMENT_ID: &str = "late-environment";
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn mcp_server_tool_call_returns_tool_result() -> Result<()> {
@@ -297,6 +311,145 @@ url = "{mcp_server_url}/mcp"
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn mcp_server_elicitation_survives_environment_runtime_refresh() -> Result<()> {
let responses_server = responses::start_mock_server().await;
let (mcp_server_url, mcp_server_handle) = start_mcp_server().await?;
let exec_listener = TcpListener::bind("127.0.0.1:0").await?;
let exec_server_url = format!("ws://{}", exec_listener.local_addr()?);
let codex_home = TempDir::new()?;
write_mock_responses_config_toml(
codex_home.path(),
&responses_server.uri(),
&BTreeMap::from([(Feature::DeferredExecutor, true)]),
/*auto_compact_limit*/ 1024,
/*requires_openai_auth*/ None,
"mock_provider",
"compact",
)?;
let config_path = codex_home.path().join("config.toml");
let mut config_toml = std::fs::read_to_string(&config_path)?;
config_toml.push_str(&format!(
r#"
[mcp_servers.{TEST_SERVER_NAME}]
url = "{mcp_server_url}/mcp"
"#
));
std::fs::write(config_path, config_toml)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let add_environment_id = mcp
.send_raw_request(
"environment/add",
Some(json!({
"environmentId": LATE_ENVIRONMENT_ID,
"execServerUrl": exec_server_url,
"connectTimeoutMs": 10_000,
})),
)
.await?;
let add_environment_response = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(add_environment_id)),
)
.await??;
let _: EnvironmentAddResponse = to_response(add_environment_response)?;
let capability_root = TempDir::new()?;
let thread_start_id = mcp
.send_thread_start_request(ThreadStartParams {
model: Some("mock-model".to_string()),
approval_policy: Some(codex_app_server_protocol::AskForApproval::UnlessTrusted),
environments: Some(vec![TurnEnvironmentParams {
environment_id: LATE_ENVIRONMENT_ID.to_string(),
cwd: codex_utils_absolute_path::AbsolutePathBuf::try_from(
capability_root.path().to_path_buf(),
)?
.into(),
}]),
selected_capability_roots: Some(vec![SelectedCapabilityRoot {
id: "late-plugin@1".to_string(),
location: CapabilityRootLocation::Environment {
environment_id: LATE_ENVIRONMENT_ID.to_string(),
path: PathUri::from_host_native_path(capability_root.path())?,
},
}]),
..Default::default()
})
.await?;
let thread_start_response = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response(thread_start_response)?;
let tool_call_request_id = mcp
.send_mcp_server_tool_call_request(McpServerToolCallParams {
thread_id: thread.id.clone(),
server: TEST_SERVER_NAME.to_string(),
tool: TEST_TOOL_NAME.to_string(),
arguments: Some(json!({"message": ELICITATION_TRIGGER_MESSAGE})),
meta: None,
})
.await?;
let server_request = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_request_message(),
)
.await??;
let ServerRequest::McpServerElicitationRequest { request_id, .. } = server_request else {
panic!("expected MCP elicitation request, got: {server_request:?}");
};
let (filesystem_request_tx, filesystem_request_rx) = oneshot::channel();
let (shutdown_tx, shutdown_rx) = oneshot::channel();
let exec_server_handle = tokio::spawn(serve_environment_until_shutdown(
exec_listener,
filesystem_request_tx,
shutdown_rx,
));
let mut filesystem_request_rx = filesystem_request_rx;
timeout(DEFAULT_READ_TIMEOUT, async {
loop {
let status_request_id = mcp
.send_raw_request("mcpServerStatus/list", Some(json!({"threadId": thread.id})))
.await?;
mcp.read_stream_until_response_message(RequestId::Integer(status_request_id))
.await?;
if filesystem_request_rx.try_recv().is_ok() {
return Ok::<_, anyhow::Error>(());
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await??;
mcp.send_response(
request_id,
serde_json::to_value(McpServerElicitationRequestResponse {
action: McpServerElicitationAction::Accept,
content: Some(json!({"confirmed": true})),
meta: None,
})?,
)
.await?;
let tool_call_response = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(tool_call_request_id)),
)
.await??;
let response: McpServerToolCallResponse = to_response(tool_call_response)?;
assert_eq!(response.content[0].get("text"), Some(&json!("accepted")));
let _ = shutdown_tx.send(());
exec_server_handle.await??;
mcp_server_handle.abort();
let _ = mcp_server_handle.await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn mcp_server_tool_call_forwards_url_elicitation() -> Result<()> {
let responses_server = responses::start_mock_server().await;
@@ -683,6 +836,86 @@ async fn start_mcp_server() -> Result<(String, JoinHandle<()>)> {
Ok((format!("http://{addr}"), handle))
}
async fn serve_environment_until_shutdown(
listener: TcpListener,
filesystem_request_tx: oneshot::Sender<()>,
mut shutdown_rx: oneshot::Receiver<()>,
) -> Result<()> {
let (stream, _) = listener.accept().await?;
let mut websocket = accept_async(stream).await?;
let initialize = read_exec_server_json(&mut websocket).await?;
assert_eq!(initialize["method"], "initialize");
websocket
.send(Message::Text(
json!({
"id": initialize["id"],
"result": {"sessionId": "test-session"},
})
.to_string()
.into(),
))
.await?;
let initialized = read_exec_server_json(&mut websocket).await?;
assert_eq!(initialized["method"], "initialized");
let environment_info = read_exec_server_json(&mut websocket).await?;
assert_eq!(environment_info["method"], "environment/info");
websocket
.send(Message::Text(
json!({
"id": environment_info["id"],
"result": {"shell": {"name": "zsh", "path": "/bin/zsh"}},
})
.to_string()
.into(),
))
.await?;
let mut filesystem_request_tx = Some(filesystem_request_tx);
loop {
let request = tokio::select! {
request = read_exec_server_json(&mut websocket) => request?,
_ = &mut shutdown_rx => return Ok(()),
};
if request["method"]
.as_str()
.is_some_and(|method| method.starts_with("fs/"))
&& let Some(tx) = filesystem_request_tx.take()
{
let _ = tx.send(());
}
if request.get("id").is_some() {
websocket
.send(Message::Text(
json!({
"id": request["id"],
"error": {"code": -32004, "message": "not found"},
})
.to_string()
.into(),
))
.await?;
}
}
}
async fn read_exec_server_json(
websocket: &mut WebSocketStream<TcpStream>,
) -> Result<serde_json::Value> {
loop {
match websocket
.next()
.await
.ok_or_else(|| anyhow::anyhow!("exec-server websocket closed"))??
{
Message::Text(text) => return Ok(serde_json::from_str(text.as_ref())?),
Message::Binary(bytes) => return Ok(serde_json::from_slice(bytes.as_ref())?),
Message::Ping(_) | Message::Pong(_) => {}
message => anyhow::bail!("expected JSON-RPC message, got {message:?}"),
}
}
}
async fn wait_for_mcp_tool_call_completed(
mcp: &mut TestAppServer,
call_id: &str,
@@ -56,6 +56,8 @@ mod request_user_input;
mod request_validation;
mod review;
mod safety_check_downgrade;
#[cfg(not(target_os = "windows"))]
mod selected_capability_stack;
mod skills_list;
mod sleep;
mod thread_archive;
@@ -160,6 +160,7 @@ async fn thread_delete_with_non_local_thread_store_does_not_create_local_persist
dynamic_tools: Vec::new(),
selected_capability_roots: Vec::new(),
multi_agent_version: None,
history_mode: Default::default(),
initial_window_id: Uuid::now_v7().to_string(),
metadata: ThreadPersistenceMetadata {
cwd: Some(codex_home.path().to_path_buf()),
@@ -0,0 +1,738 @@
use std::process::Stdio;
use std::time::Duration;
use anyhow::Context;
use anyhow::Result;
use app_test_support::ChatGptAuthFixture;
use app_test_support::TestAppServer;
use app_test_support::to_response;
use app_test_support::write_chatgpt_auth;
use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url;
use codex_app_server_protocol::AppInfo;
use codex_app_server_protocol::CapabilityRootLocation;
use codex_app_server_protocol::EnvironmentAddResponse;
use codex_app_server_protocol::ListMcpServerStatusParams;
use codex_app_server_protocol::ListMcpServerStatusResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SelectedCapabilityRoot;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::ThreadResumeParams;
use codex_app_server_protocol::ThreadResumeResponse;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnEnvironmentParams;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::UserInput;
use codex_config::types::AuthCredentialsStoreMode;
use codex_exec_server::LOCAL_ENVIRONMENT_ID;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Settings;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
use core_test_support::process::wait_for_pid_file;
use core_test_support::responses;
use core_test_support::responses::ResponsesRequest;
use core_test_support::stdio_server_bin;
use pretty_assertions::assert_eq;
use pretty_assertions::assert_ne;
use serde_json::json;
use tempfile::TempDir;
use tokio::io::AsyncBufReadExt;
use tokio::io::BufReader;
use tokio::process::Child;
use tokio::process::Command;
use tokio::time::timeout;
use super::app_list::connector_tool;
use super::app_list::start_apps_server_with_delays;
const READ_TIMEOUT: Duration = Duration::from_secs(20);
const EXECUTOR_ID: &str = "executor-1";
const EXECUTOR_ENV_NAME: &str = "MCP_EXECUTOR_MARKER";
const EXECUTOR_ENV_VALUE: &str = "executor-only";
const PLUGIN_ID: &str = "executor-demo@1";
const PLUGIN_DISPLAY_NAME: &str = "Executor Demo";
const SKILL_NAME: &str = "executor-demo:deploy";
const SKILL_DESCRIPTION: &str = "Deploy through the selected executor.";
const SKILL_BODY_MARKER: &str = "SELECTED_EXECUTOR_SKILL_BODY";
const LOCAL_SKILL_BODY_MARKER: &str = "COLLIDING_LOCAL_SKILL_BODY";
const NO_SELECTED_SKILLS_MESSAGE: &str = "No selected-environment skills are currently available.";
const MCP_SERVER_NAME: &str = "executor_probe";
const MCP_CALL_ID: &str = "selected-executor-mcp-call";
const CONNECTOR_ID: &str = "calendar";
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn selected_capability_stack_tracks_environment_availability_and_resume() -> Result<()> {
let responses_server = responses::start_mock_server().await;
let (apps_url, apps_server_handle) = start_apps_server_with_delays(
vec![AppInfo {
id: CONNECTOR_ID.to_string(),
name: "Calendar".to_string(),
description: None,
logo_url: None,
logo_url_dark: None,
icon_assets: None,
icon_dark_assets: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: None,
is_accessible: false,
is_enabled: true,
plugin_display_names: Vec::new(),
}],
vec![connector_tool(CONNECTOR_ID, "Calendar")?],
Duration::ZERO,
Duration::ZERO,
)
.await?;
let fixture = selected_capability_fixture(&responses_server.uri(), &apps_url)?;
let response_mock = responses::mount_sse_sequence(
&responses_server,
vec![
responses::sse(vec![
responses::ev_response_created("environment-unavailable"),
responses::ev_assistant_message("unavailable-message", "Waiting"),
responses::ev_completed("environment-unavailable"),
]),
responses::sse(vec![
responses::ev_response_created("environment-available-call"),
responses::ev_function_call_with_namespace(
MCP_CALL_ID,
&format!("mcp__{MCP_SERVER_NAME}"),
"echo",
&json!({
"message": "hello from the selected executor",
"env_var": EXECUTOR_ENV_NAME,
})
.to_string(),
),
responses::ev_completed("environment-available-call"),
]),
responses::sse(vec![
responses::ev_response_created("environment-available-done"),
responses::ev_assistant_message("available-message", "Done"),
responses::ev_completed("environment-available-done"),
]),
responses::sse(vec![
responses::ev_response_created("unchanged-step"),
responses::ev_assistant_message("unchanged-message", "Still ready"),
responses::ev_completed("unchanged-step"),
]),
responses::sse(vec![
responses::ev_response_created("resumed-unavailable-step"),
responses::ev_assistant_message(
"resumed-unavailable-message",
"Unavailable after resume",
),
responses::ev_completed("resumed-unavailable-step"),
]),
responses::sse(vec![
responses::ev_response_created("reattached-step"),
responses::ev_assistant_message("reattached-message", "Ready after reattach"),
responses::ev_completed("reattached-step"),
]),
],
)
.await;
let mut app_server = TestAppServer::new(fixture.codex_home.path()).await?;
timeout(READ_TIMEOUT, app_server.initialize()).await??;
let thread_id = start_thread(
&mut app_server,
fixture.selected_root.clone(),
fixture.environment_cwd.clone(),
)
.await?;
run_turn(
&mut app_server,
&thread_id,
"Inspect the current capabilities",
fixture.environment_cwd.clone(),
)
.await?;
let initial_requests = response_mock.requests();
assert_selected_capabilities_absent(&initial_requests[0]);
let mut exec_server =
spawn_exec_server(fixture.codex_home.path(), &fixture.exec_server_url).await?;
add_environment(&mut app_server, &fixture.exec_server_url).await?;
wait_for_selected_mcp_server(&mut app_server, &thread_id).await?;
run_turn(
&mut app_server,
&thread_id,
&format!("Use ${SKILL_NAME} and call its selected executor MCP"),
fixture.environment_cwd.clone(),
)
.await?;
let first_mcp_pid = wait_for_pid_file(&fixture.pid_file).await?;
run_turn(
&mut app_server,
&thread_id,
"Continue with the same selected capabilities",
fixture.environment_cwd.clone(),
)
.await?;
assert_eq!(first_mcp_pid, wait_for_pid_file(&fixture.pid_file).await?);
exec_server.kill().await?;
drop(app_server);
std::fs::remove_file(&fixture.pid_file)?;
let mut app_server = TestAppServer::new(fixture.codex_home.path()).await?;
timeout(READ_TIMEOUT, app_server.initialize()).await??;
let request_id = app_server
.send_thread_resume_request(ThreadResumeParams {
thread_id: thread_id.clone(),
..Default::default()
})
.await?;
let response = timeout(
READ_TIMEOUT,
app_server.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let ThreadResumeResponse { thread, .. } = to_response(response)?;
assert_eq!(thread_id, thread.id);
run_turn(
&mut app_server,
&thread_id,
"Inspect capabilities while the selected executor is unavailable",
fixture.environment_cwd.clone(),
)
.await?;
let requests = response_mock.requests();
assert_eq!(5, requests.len());
assert_selected_plugin_tools_absent(&requests[4]);
assert!(
latest_selected_skill_update(&requests[4])
.is_some_and(|text| text.contains(NO_SELECTED_SKILLS_MESSAGE))
);
exec_server = spawn_exec_server(fixture.codex_home.path(), &fixture.exec_server_url).await?;
add_environment(&mut app_server, &fixture.exec_server_url).await?;
wait_for_selected_mcp_server(&mut app_server, &thread_id).await?;
run_turn(
&mut app_server,
&thread_id,
&format!("Use ${SKILL_NAME} after reattaching the selected executor"),
fixture.environment_cwd,
)
.await?;
let resumed_mcp_pid = wait_for_pid_file(&fixture.pid_file).await?;
assert_ne!(first_mcp_pid, resumed_mcp_pid);
let requests = response_mock.requests();
assert_eq!(6, requests.len());
for request in &requests[1..4] {
assert_selected_skill_is_injected(request, /*expected_count*/ 1);
assert_selected_plugin_tools(request);
}
assert_selected_skill_is_injected(&requests[5], /*expected_count*/ 2);
assert_selected_plugin_tools(&requests[5]);
let output = requests[2].function_call_output(MCP_CALL_ID);
let output = output["output"]
.as_str()
.expect("MCP function output should be text");
assert!(output.contains("ECHOING: hello from the selected executor"));
assert!(output.contains(EXECUTOR_ENV_VALUE));
exec_server.kill().await?;
apps_server_handle.abort();
let _ = apps_server_handle.await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn selected_capabilities_become_available_between_samples_in_one_turn() -> Result<()> {
const USER_INPUT_CALL_ID: &str = "pause-for-environment";
let responses_server = responses::start_mock_server().await;
let (apps_url, apps_server_handle) = start_apps_server_with_delays(
vec![AppInfo {
id: CONNECTOR_ID.to_string(),
name: "Calendar".to_string(),
description: None,
logo_url: None,
logo_url_dark: None,
icon_assets: None,
icon_dark_assets: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: None,
is_accessible: false,
is_enabled: true,
plugin_display_names: Vec::new(),
}],
vec![connector_tool(CONNECTOR_ID, "Calendar")?],
Duration::ZERO,
Duration::ZERO,
)
.await?;
let fixture = selected_capability_fixture(&responses_server.uri(), &apps_url)?;
let response_mock = responses::mount_sse_sequence(
&responses_server,
vec![
responses::sse(vec![
responses::ev_response_created("environment-pending"),
responses::ev_function_call(
USER_INPUT_CALL_ID,
"request_user_input",
&json!({
"questions": [{
"id": "continue",
"header": "Continue",
"question": "Continue after the executor is attached?",
"options": [{
"label": "Yes (Recommended)",
"description": "Continue the same turn."
}, {
"label": "No",
"description": "Stop here."
}]
}],
"autoResolutionMs": 60_000
})
.to_string(),
),
responses::ev_completed("environment-pending"),
]),
responses::sse(vec![
responses::ev_response_created("environment-ready-call"),
responses::ev_function_call_with_namespace(
MCP_CALL_ID,
&format!("mcp__{MCP_SERVER_NAME}"),
"echo",
&json!({
"message": "same turn",
"env_var": EXECUTOR_ENV_NAME,
})
.to_string(),
),
responses::ev_completed("environment-ready-call"),
]),
responses::sse(vec![
responses::ev_response_created("same-turn-done"),
responses::ev_assistant_message("same-turn-message", "Done"),
responses::ev_completed("same-turn-done"),
]),
],
)
.await;
let mut app_server = TestAppServer::new(fixture.codex_home.path()).await?;
timeout(READ_TIMEOUT, app_server.initialize()).await??;
let thread_id = start_thread(
&mut app_server,
fixture.selected_root,
fixture.environment_cwd.clone(),
)
.await?;
let turn_start_id = app_server
.send_turn_start_request(TurnStartParams {
thread_id,
input: vec![UserInput::Text {
text: "Use the executor when it becomes ready.".to_string(),
text_elements: Vec::new(),
}],
environments: Some(vec![TurnEnvironmentParams {
environment_id: LOCAL_ENVIRONMENT_ID.to_string(),
cwd: fixture.environment_cwd.into(),
}]),
collaboration_mode: Some(CollaborationMode {
mode: ModeKind::Plan,
settings: Settings {
model: "mock-model".to_string(),
reasoning_effort: None,
developer_instructions: None,
},
}),
..Default::default()
})
.await?;
timeout(
READ_TIMEOUT,
app_server.read_stream_until_response_message(RequestId::Integer(turn_start_id)),
)
.await??;
let request = timeout(READ_TIMEOUT, app_server.read_stream_until_request_message()).await??;
let ServerRequest::ToolRequestUserInput { request_id, .. } = request else {
panic!("expected request_user_input, got {request:?}");
};
let requests = response_mock.requests();
assert_eq!(1, requests.len());
assert_selected_capabilities_absent(&requests[0]);
let mut exec_server =
spawn_exec_server(fixture.codex_home.path(), &fixture.exec_server_url).await?;
add_environment(&mut app_server, &fixture.exec_server_url).await?;
tokio::time::sleep(Duration::from_millis(200)).await;
app_server
.send_response(
request_id,
json!({
"answers": {
"continue": { "answers": ["yes"] }
}
}),
)
.await?;
timeout(
READ_TIMEOUT,
app_server.read_stream_until_notification_message("turn/completed"),
)
.await??;
let requests = response_mock.requests();
assert_eq!(3, requests.len());
assert_selected_skill_catalog_available(&requests[1]);
assert_selected_plugin_tools(&requests[1]);
assert_selected_plugin_tools(&requests[2]);
let output = requests[2].function_call_output(MCP_CALL_ID);
let output = output["output"]
.as_str()
.expect("MCP function output should be text");
assert!(output.contains("ECHOING: same turn"));
assert!(output.contains(EXECUTOR_ENV_VALUE));
wait_for_pid_file(&fixture.pid_file).await?;
exec_server.kill().await?;
apps_server_handle.abort();
let _ = apps_server_handle.await;
Ok(())
}
struct SelectedCapabilityFixture {
codex_home: TempDir,
_plugin: TempDir,
pid_file: std::path::PathBuf,
exec_server_url: String,
selected_root: SelectedCapabilityRoot,
environment_cwd: AbsolutePathBuf,
}
fn selected_capability_fixture(
responses_server_uri: &str,
apps_url: &str,
) -> Result<SelectedCapabilityFixture> {
let codex_home = TempDir::new()?;
write_mock_responses_config_toml_with_chatgpt_base_url(
codex_home.path(),
responses_server_uri,
apps_url,
)?;
let config_path = codex_home.path().join("config.toml");
let config = std::fs::read_to_string(&config_path)?.replacen(
"model_provider = \"mock_provider\"",
"mcp_oauth_credentials_store = \"file\"\nmodel_provider = \"mock_provider\"",
1,
);
std::fs::write(
config_path,
format!(
"{config}\n[features]\napps = true\ndeferred_executor = true\n\n[skills]\ninclude_instructions = true\n"
),
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.email("selected-capability-stack@example.com")
.plan_type("pro")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
// Reserve the URL before app-server starts. The configured environment initially fails to
// connect, then environment/add points the same stable ID at the same URL once it is live.
let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
let exec_server_url = format!("ws://{}", listener.local_addr()?);
drop(listener);
std::fs::write(
codex_home.path().join("environments.toml"),
format!(
"default = \"{EXECUTOR_ID}\"\ninclude_local = true\n\n[[environments]]\nid = \"{EXECUTOR_ID}\"\nurl = \"{exec_server_url}\"\nconnect_timeout_sec = 0.05\n"
),
)?;
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_SKILL_BODY_MARKER}\n"
),
)?;
let plugin = TempDir::new()?;
let manifest_dir = plugin.path().join(".codex-plugin");
let skill_dir = plugin.path().join("skills/deploy");
let pid_file = plugin.path().join("executor-mcp.pid");
std::fs::create_dir_all(&manifest_dir)?;
std::fs::create_dir_all(&skill_dir)?;
std::fs::write(
manifest_dir.join("plugin.json"),
r#"{"name":"executor-demo","apps":"./.app.json","interface":{"displayName":"Executor Demo"}}"#,
)?;
std::fs::write(
skill_dir.join("SKILL.md"),
format!(
"---\nname: deploy\ndescription: {SKILL_DESCRIPTION}\n---\n\n{SKILL_BODY_MARKER}\n"
),
)?;
std::fs::write(
plugin.path().join(".app.json"),
format!(r#"{{"apps":{{"calendar":{{"id":"{CONNECTOR_ID}"}}}}}}"#),
)?;
std::fs::write(
plugin.path().join(".mcp.json"),
serde_json::to_vec_pretty(&json!({
"mcpServers": {
(MCP_SERVER_NAME): {
"command": stdio_server_bin()?,
"env": {
"MCP_TEST_PID_FILE": pid_file.to_string_lossy(),
},
"env_vars": [EXECUTOR_ENV_NAME],
"startup_timeout_sec": 10,
}
}
}))?,
)?;
let selected_root = SelectedCapabilityRoot {
id: PLUGIN_ID.to_string(),
location: CapabilityRootLocation::Environment {
environment_id: EXECUTOR_ID.to_string(),
path: PathUri::from_host_native_path(plugin.path())?,
},
};
let environment_cwd = AbsolutePathBuf::try_from(plugin.path().to_path_buf())?;
Ok(SelectedCapabilityFixture {
codex_home,
_plugin: plugin,
pid_file,
exec_server_url,
selected_root,
environment_cwd,
})
}
fn assert_selected_capabilities_absent(request: &ResponsesRequest) {
assert!(
request
.message_input_texts("developer")
.into_iter()
.all(|text| !text.contains(SKILL_DESCRIPTION))
);
assert_selected_plugin_tools_absent(request);
}
fn assert_selected_plugin_tools_absent(request: &ResponsesRequest) {
assert!(
request
.tool_by_name(&format!("mcp__{MCP_SERVER_NAME}"), "echo")
.is_none()
);
let connector = request
.tool_by_name("mcp__codex_apps__calendar", "connector_calendar")
.expect("host connector should remain model-visible");
assert!(
connector["description"]
.as_str()
.is_some_and(|description| !description.contains(PLUGIN_DISPLAY_NAME))
);
}
fn assert_selected_skill_is_injected(request: &ResponsesRequest, expected_count: usize) {
assert_selected_skill_catalog_available(request);
let skill_fragments = request
.message_input_texts("user")
.into_iter()
.filter(|text| text.starts_with("<skill>"))
.collect::<Vec<_>>();
assert_eq!(expected_count, skill_fragments.len());
for fragment in skill_fragments {
assert!(fragment.contains(&format!("<name>{SKILL_NAME}</name>")));
assert!(fragment.contains(SKILL_BODY_MARKER));
assert!(!fragment.contains(LOCAL_SKILL_BODY_MARKER));
}
}
fn assert_selected_skill_catalog_available(request: &ResponsesRequest) {
let catalog_fragment = latest_selected_skill_update(request)
.expect("selected skill catalog update should be model-visible");
assert!(catalog_fragment.contains(SKILL_DESCRIPTION));
assert!(catalog_fragment.contains("environment resource:"));
}
fn latest_selected_skill_update(request: &ResponsesRequest) -> Option<String> {
request
.message_input_texts("developer")
.into_iter()
.rfind(|text| text.contains(SKILL_DESCRIPTION) || text.contains(NO_SELECTED_SKILLS_MESSAGE))
}
fn assert_selected_plugin_tools(request: &ResponsesRequest) {
assert!(
request
.tool_by_name(&format!("mcp__{MCP_SERVER_NAME}"), "echo")
.is_some()
);
let connector = request
.tool_by_name("mcp__codex_apps__calendar", "connector_calendar")
.expect("selected connector should be model-visible");
assert!(
connector["description"]
.as_str()
.is_some_and(|description| description.contains(PLUGIN_DISPLAY_NAME))
);
}
async fn start_thread(
app_server: &mut TestAppServer,
selected_root: SelectedCapabilityRoot,
environment_cwd: AbsolutePathBuf,
) -> Result<String> {
let request_id = app_server
.send_thread_start_request(ThreadStartParams {
model: Some("mock-model".to_string()),
environments: Some(vec![TurnEnvironmentParams {
environment_id: LOCAL_ENVIRONMENT_ID.to_string(),
cwd: environment_cwd.into(),
}]),
selected_capability_roots: Some(vec![selected_root]),
..Default::default()
})
.await?;
let response = timeout(
READ_TIMEOUT,
app_server.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response(response)?;
Ok(thread.id)
}
async fn run_turn(
app_server: &mut TestAppServer,
thread_id: &str,
text: &str,
environment_cwd: AbsolutePathBuf,
) -> Result<()> {
let request_id = app_server
.send_turn_start_request(TurnStartParams {
thread_id: thread_id.to_string(),
input: vec![UserInput::Text {
text: text.to_string(),
text_elements: Vec::new(),
}],
environments: Some(vec![TurnEnvironmentParams {
environment_id: LOCAL_ENVIRONMENT_ID.to_string(),
cwd: environment_cwd.into(),
}]),
..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??;
Ok(())
}
async fn add_environment(app_server: &mut TestAppServer, exec_server_url: &str) -> Result<()> {
let request_id = app_server
.send_raw_request(
"environment/add",
Some(json!({
"environmentId": EXECUTOR_ID,
"execServerUrl": exec_server_url,
"connectTimeoutMs": 10_000,
})),
)
.await?;
let response = timeout(
READ_TIMEOUT,
app_server.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let _: EnvironmentAddResponse = to_response(response)?;
Ok(())
}
async fn wait_for_selected_mcp_server(
app_server: &mut TestAppServer,
thread_id: &str,
) -> Result<()> {
timeout(READ_TIMEOUT, async {
loop {
let request_id = app_server
.send_list_mcp_server_status_request(ListMcpServerStatusParams {
cursor: None,
limit: None,
detail: None,
thread_id: Some(thread_id.to_string()),
})
.await?;
let response = app_server
.read_stream_until_response_message(RequestId::Integer(request_id))
.await?;
let response: ListMcpServerStatusResponse = to_response(response)?;
if response
.data
.iter()
.any(|server| server.name == MCP_SERVER_NAME)
{
return Ok::<_, anyhow::Error>(());
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await??;
Ok(())
}
async fn spawn_exec_server(codex_home: &std::path::Path, url: &str) -> Result<Child> {
let mut child = Command::new(codex_utils_cargo_bin::cargo_bin("codex")?)
.args(["exec-server", "--listen", url])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.kill_on_drop(true)
.env("CODEX_HOME", codex_home)
.env(EXECUTOR_ENV_NAME, EXECUTOR_ENV_VALUE)
.spawn()?;
let stdout = child
.stdout
.take()
.context("exec-server stdout was not captured")?;
let mut lines = BufReader::new(stdout).lines();
loop {
let line = timeout(READ_TIMEOUT, lines.next_line())
.await
.context("timed out waiting for exec-server URL")??
.context("exec-server exited before printing its URL")?;
if line.trim() == url {
return Ok(child);
}
}
}
@@ -897,6 +897,7 @@ async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<(
personality: None,
multi_agent_mode: None,
ephemeral: None,
history_mode: None,
session_start_source: None,
thread_source: None,
dynamic_tools: None,
@@ -251,6 +251,119 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn thread_fork_at_last_turn_id_keeps_only_terminal_prefix() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let start_id = mcp
.send_thread_start_request(ThreadStartParams::default())
.await?;
let start_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(start_id)),
)
.await??;
let ThreadStartResponse {
thread: source_thread,
..
} = to_response::<ThreadStartResponse>(start_resp)?;
let source_thread_id = source_thread.id.clone();
let source_path = source_thread.path.expect("source thread path");
let mut turn_ids = Vec::new();
for text in ["first", "second", "third"] {
let turn_request_id = mcp
.send_turn_start_request(TurnStartParams {
thread_id: source_thread_id.clone(),
client_user_message_id: None,
input: vec![UserInput::Text {
text: text.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_request_id)),
)
.await??;
let TurnStartResponse { turn } = to_response::<TurnStartResponse>(turn_resp)?;
turn_ids.push(turn.id);
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
}
let original_contents = std::fs::read_to_string(source_path.as_path())?;
let fork_id = mcp
.send_thread_fork_request(ThreadForkParams {
thread_id: source_thread_id.clone(),
last_turn_id: Some(turn_ids[1].clone()),
..Default::default()
})
.await?;
let fork_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(fork_id)),
)
.await??;
let ThreadForkResponse {
thread: forked_thread,
..
} = to_response::<ThreadForkResponse>(fork_resp)?;
assert_eq!(
forked_thread
.turns
.iter()
.map(|turn| turn.id.clone())
.collect::<Vec<_>>(),
turn_ids[..2]
);
assert!(
forked_thread
.turns
.iter()
.all(|turn| turn.status == TurnStatus::Completed)
);
assert_eq!(forked_thread.forked_from_id, Some(source_thread_id));
assert_eq!(forked_thread.preview, "first");
assert_eq!(
std::fs::read_to_string(source_path.as_path())?,
original_contents,
"forking at a turn must not mutate the source rollout"
);
let forked_path = forked_thread.path.clone().expect("forked thread path");
let forked_contents = std::fs::read_to_string(forked_path.as_path())?;
assert!(forked_contents.contains(turn_ids[1].as_str()));
assert!(!forked_contents.contains(turn_ids[2].as_str()));
let started = loop {
let notification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("thread/started"),
)
.await??;
let started: ThreadStartedNotification =
serde_json::from_value(notification.params.expect("params must be present"))?;
if started.thread.id == forked_thread.id {
break started;
}
};
assert!(started.thread.turns.is_empty());
Ok(())
}
#[tokio::test]
async fn thread_fork_inherits_explicit_source_name_from_session_index() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
@@ -488,6 +601,7 @@ async fn thread_fork_tracks_thread_initialized_analytics() -> Result<()> {
event,
&thread.id,
&thread.session_id,
"codex",
"mock-model",
"forked",
"user",
@@ -18,6 +18,7 @@ use codex_app_server_protocol::SessionSource;
use codex_app_server_protocol::SortDirection;
use codex_app_server_protocol::ThreadForkParams;
use codex_app_server_protocol::ThreadForkResponse;
use codex_app_server_protocol::ThreadHistoryMode;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemsListParams;
use codex_app_server_protocol::ThreadListParams;
@@ -196,6 +197,121 @@ async fn thread_read_can_include_turns() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn paginated_stored_thread_allows_metadata_discovery_and_rejects_legacy_history_paths()
-> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
let conversation_id = create_fake_rollout_with_text_elements(
codex_home.path(),
"2025-01-05T12-00-00",
"2025-01-05T12:00:00Z",
"Saved user message",
Vec::new(),
Some("mock_provider"),
/*git_info*/ None,
)?;
set_rollout_history_mode(
rollout_path(codex_home.path(), "2025-01-05T12-00-00", &conversation_id).as_path(),
ThreadHistoryMode::Paginated,
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let read_id = mcp
.send_thread_read_request(ThreadReadParams {
thread_id: conversation_id.clone(),
include_turns: false,
})
.await?;
let read_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(read_id)),
)
.await??;
let ThreadReadResponse { thread } = to_response::<ThreadReadResponse>(read_resp)?;
assert_eq!(thread.history_mode, ThreadHistoryMode::Paginated);
assert!(thread.turns.is_empty());
let list_id = mcp
.send_thread_list_request(ThreadListParams {
cursor: None,
limit: Some(50),
sort_key: None,
sort_direction: None,
model_providers: Some(vec!["mock_provider".to_string()]),
source_kinds: None,
archived: None,
cwd: None,
use_state_db_only: false,
search_term: None,
parent_thread_id: None,
ancestor_thread_id: None,
})
.await?;
let list_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(list_id)),
)
.await??;
let ThreadListResponse { data, .. } = to_response::<ThreadListResponse>(list_resp)?;
let listed = data
.iter()
.find(|thread| thread.id == conversation_id)
.expect("thread/list should include paginated thread");
assert_eq!(listed.history_mode, ThreadHistoryMode::Paginated);
let read_id = mcp
.send_thread_read_request(ThreadReadParams {
thread_id: conversation_id.clone(),
include_turns: true,
})
.await?;
assert_paginated_threads_unsupported(
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(read_id)),
)
.await??,
);
let turns_list_id = mcp
.send_thread_turns_list_request(ThreadTurnsListParams {
thread_id: conversation_id.clone(),
cursor: None,
limit: None,
sort_direction: None,
items_view: None,
})
.await?;
assert_paginated_threads_unsupported(
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(turns_list_id)),
)
.await??,
);
let resume_id = mcp
.send_thread_resume_request(ThreadResumeParams {
thread_id: conversation_id,
..Default::default()
})
.await?;
assert_paginated_threads_unsupported(
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(resume_id)),
)
.await??,
);
Ok(())
}
#[tokio::test]
async fn thread_turns_list_can_page_backward_and_forward() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
@@ -1371,6 +1487,7 @@ async fn seed_pathless_store_thread(
dynamic_tools: Vec::new(),
selected_capability_roots: Vec::new(),
multi_agent_version: None,
history_mode: Default::default(),
initial_window_id: Uuid::now_v7().to_string(),
metadata: ThreadPersistenceMetadata {
cwd: None,
@@ -1411,6 +1528,26 @@ fn store_history_items() -> Vec<RolloutItem> {
))]
}
fn set_rollout_history_mode(path: &Path, history_mode: ThreadHistoryMode) -> Result<()> {
let mut lines = std::fs::read_to_string(path)?
.lines()
.map(serde_json::from_str::<Value>)
.collect::<Result<Vec<_>, _>>()?;
lines[0]["payload"]["history_mode"] = serde_json::to_value(history_mode)?;
let contents = lines
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n");
std::fs::write(path, format!("{contents}\n"))?;
Ok(())
}
fn assert_paginated_threads_unsupported(err: JSONRPCError) {
assert_eq!(err.error.code, -32601);
assert_eq!(err.error.message, "paginated_threads is not supported yet");
}
fn create_config_toml_with_thread_store(codex_home: &Path, store_id: &str) -> std::io::Result<()> {
let config_toml = codex_home.join("config.toml");
std::fs::write(
@@ -107,6 +107,7 @@ use super::analytics::mount_analytics_capture;
use super::analytics::thread_initialized_event;
use super::analytics::wait_for_analytics_payload;
use super::analytics::wait_for_goal_event;
use super::analytics::wait_for_matching_analytics_event;
#[cfg(windows)]
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25);
@@ -573,11 +574,12 @@ async fn thread_resume_tracks_thread_initialized_analytics() -> Result<()> {
Some("mock_provider"),
/*git_info*/ None,
)?;
set_thread_source_on_fake_rollout(
set_session_meta_on_fake_rollout(
codex_home.path(),
"2025-01-05T12-00-00",
&conversation_id,
"user",
"codex_work_desktop",
)?;
let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?;
@@ -607,6 +609,7 @@ async fn thread_resume_tracks_thread_initialized_analytics() -> Result<()> {
event,
&thread.id,
&thread.session_id,
"codex_work_desktop",
"gpt-5.3-codex",
"resumed",
"user",
@@ -615,11 +618,94 @@ async fn thread_resume_tracks_thread_initialized_analytics() -> Result<()> {
Ok(())
}
fn set_thread_source_on_fake_rollout(
#[tokio::test]
async fn thread_resume_running_thread_tracks_thread_originator_in_analytics() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
create_config_toml_with_chatgpt_base_url(codex_home.path(), &server.uri(), &server.uri())?;
mount_analytics_capture(&server, codex_home.path()).await?;
let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let start_id = mcp
.send_thread_start_request(ThreadStartParams {
model: Some("mock-model".to_string()),
thread_source: Some(ThreadSource::User),
service_name: Some("codex_work_desktop".to_string()),
..Default::default()
})
.await?;
let start_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(start_id)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(start_resp)?;
let turn_id = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
client_user_message_id: None,
input: vec![UserInput::Text {
text: "materialize rollout".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn_id)),
)
.await??;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
let resume_id = mcp
.send_thread_resume_request(ThreadResumeParams {
thread_id: thread.id.clone(),
exclude_turns: true,
..Default::default()
})
.await?;
let resume_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(resume_id)),
)
.await??;
let ThreadResumeResponse {
thread: resumed, ..
} = to_response::<ThreadResumeResponse>(resume_resp)?;
let event = wait_for_matching_analytics_event(&server, DEFAULT_READ_TIMEOUT, |event| {
event["event_type"] == "codex_thread_initialized"
&& event["event_params"]["thread_id"] == resumed.id
&& event["event_params"]["initialization_mode"] == "resumed"
})
.await?;
assert_basic_thread_initialized_event(
&event,
&resumed.id,
&resumed.session_id,
"codex_work_desktop",
"mock-model",
"resumed",
"user",
);
Ok(())
}
fn set_session_meta_on_fake_rollout(
codex_home: &std::path::Path,
filename_ts: &str,
thread_id: &str,
thread_source: &str,
originator: &str,
) -> Result<()> {
let path = rollout_path(codex_home, filename_ts, thread_id);
let contents = std::fs::read_to_string(&path)?;
@@ -629,6 +715,7 @@ fn set_thread_source_on_fake_rollout(
.ok_or_else(|| anyhow::anyhow!("fake rollout missing session meta"))?;
let mut session_meta: serde_json::Value = serde_json::from_str(session_meta)?;
session_meta["payload"]["thread_source"] = serde_json::json!(thread_source);
session_meta["payload"]["originator"] = serde_json::json!(originator);
let remaining = lines.collect::<Vec<_>>().join("\n");
std::fs::write(&path, format!("{session_meta}\n{remaining}\n"))?;
Ok(())
@@ -747,6 +834,9 @@ async fn thread_resume_redacts_payloads_for_chatgpt_remote_clients() -> Result<(
connector_id: "calendar".to_string(),
link_id: Some("link_calendar".to_string()),
resource_uri: Some("ui://widget/lookup.html".to_string()),
app_name: Some("Calendar".to_string()),
template_id: Some("calendar_template".to_string()),
action_name: Some("lookup".to_string()),
})
);
let result = result.as_ref().expect("redacted MCP result");
@@ -797,6 +887,9 @@ async fn thread_resume_redacts_payloads_for_chatgpt_remote_clients() -> Result<(
connector_id: "calendar".to_string(),
link_id: Some("link_calendar".to_string()),
resource_uri: Some("ui://widget/lookup.html".to_string()),
app_name: Some("Calendar".to_string()),
template_id: Some("calendar_template".to_string()),
action_name: Some("lookup".to_string()),
})
);
let result = result.as_ref().expect("normal MCP result");
@@ -903,6 +996,9 @@ fn append_resume_redaction_history(
connector_id: Some("calendar".to_string()),
mcp_app_resource_uri: Some("ui://widget/lookup.html".to_string()),
link_id: Some("link_calendar".to_string()),
app_name: Some("Calendar".to_string()),
template_id: Some("calendar_template".to_string()),
action_name: Some("lookup".to_string()),
plugin_id: None,
duration: Duration::from_millis(8),
result: Ok(CallToolResult {
@@ -2072,6 +2168,7 @@ stream_max_retries = 0
dynamic_tools: None,
selected_capability_roots: Vec::new(),
memory_mode: None,
history_mode: Default::default(),
multi_agent_version: None,
context_window: None,
};
@@ -15,6 +15,7 @@ use codex_app_server_protocol::McpServerStatusUpdatedNotification;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SandboxMode;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ThreadHistoryMode;
use codex_app_server_protocol::ThreadSource;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
@@ -248,6 +249,11 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> {
Some(false),
"new persistent threads should serialize `ephemeral: false`"
);
assert_eq!(
thread_json.get("historyMode").and_then(Value::as_str),
Some("legacy"),
"new threads should serialize `historyMode: legacy`"
);
assert_eq!(
thread_json.get("threadSource").and_then(Value::as_str),
Some("user"),
@@ -308,6 +314,50 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn thread_start_history_mode_accepts_legacy_and_rejects_paginated() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?;
let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_thread_start_request_with_auto_env(ThreadStartParams {
history_mode: Some(ThreadHistoryMode::Legacy),
..Default::default()
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response(response)?;
assert_eq!(thread.history_mode, ThreadHistoryMode::Legacy);
let request_id = mcp
.send_thread_start_request_with_auto_env(ThreadStartParams {
history_mode: Some(ThreadHistoryMode::Paginated),
..Default::default()
})
.await?;
let error: JSONRPCError = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(error.error.code, -32601);
assert_eq!(
error.error.message,
"paginated_threads is not supported yet"
);
Ok(())
}
#[tokio::test]
async fn thread_start_accepts_absolute_runtime_workspace_roots() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
@@ -655,6 +705,7 @@ async fn thread_start_tracks_thread_initialized_analytics() -> Result<()> {
let req_id = mcp
.send_thread_start_request(ThreadStartParams {
thread_source: Some(ThreadSource::User),
service_name: Some("codex_work_desktop".to_string()),
..Default::default()
})
.await?;
@@ -672,6 +723,7 @@ async fn thread_start_tracks_thread_initialized_analytics() -> Result<()> {
event,
&thread.id,
&thread.session_id,
"codex_work_desktop",
"mock-model",
"new",
"user",
@@ -220,6 +220,7 @@ async fn thread_unarchive_preserves_pathless_store_metadata() -> Result<()> {
dynamic_tools: Vec::new(),
selected_capability_roots: Vec::new(),
multi_agent_version: None,
history_mode: Default::default(),
initial_window_id: Uuid::now_v7().to_string(),
metadata: ThreadPersistenceMetadata {
cwd: None,
@@ -1,6 +1,5 @@
use anyhow::Context;
use anyhow::Result;
use app_test_support::DEFAULT_CLIENT_NAME;
use app_test_support::TestAppServer;
use app_test_support::create_apply_patch_sse_response;
use app_test_support::create_exec_command_sse_response;
@@ -840,7 +839,7 @@ async fn thread_start_omits_empty_instruction_overrides_from_model_request() ->
}
#[tokio::test]
async fn turn_start_tracks_turn_event_analytics() -> Result<()> {
async fn turn_start_tracks_thread_originator_in_analytics() -> Result<()> {
let server = responses::start_mock_server().await;
let response_mock = responses::mount_response_sequence(
&server,
@@ -875,6 +874,7 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> {
.send_thread_start_request(ThreadStartParams {
model: Some("mock-model".to_string()),
thread_source: Some(ThreadSource::User),
service_name: Some("codex_work_desktop".to_string()),
..Default::default()
})
.await?;
@@ -919,7 +919,7 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> {
assert_eq!(event["event_params"]["turn_id"], turn.id);
assert_eq!(
event["event_params"]["app_server_client"]["product_client_id"],
DEFAULT_CLIENT_NAME
"codex_work_desktop"
);
assert_eq!(event["event_params"]["model"], "mock-model");
assert_eq!(event["event_params"]["model_provider"], "mock_provider");
@@ -1817,7 +1817,9 @@ async fn turn_start_ignores_deprecated_multi_agent_mode() -> Result<()> {
.single_request()
.message_input_texts("developer");
assert!(developer_texts.iter().any(|text| {
text.contains("Do not spawn sub-agents unless the user explicitly asks for sub-agents")
text.contains(
"Do not spawn sub-agents unless the user or applicable AGENTS.md/skill instructions explicitly ask for sub-agents",
)
}));
assert!(
!developer_texts
@@ -1898,8 +1900,9 @@ async fn thread_start_ignores_deprecated_multi_agent_mode() -> Result<()> {
.message_input_texts("developer");
assert!(developer_texts.iter().any(|text| {
text.contains(MULTI_AGENT_MODE_OPEN_TAG)
&& text
.contains("Do not spawn sub-agents unless the user explicitly asks for sub-agents")
&& text.contains(
"Do not spawn sub-agents unless the user or applicable AGENTS.md/skill instructions explicitly ask for sub-agents",
)
}));
assert!(
!developer_texts

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