Merge 35dcf0713c into sapling-pr-archive-bolinfest

This commit is contained in:
Michael Bolin
2026-05-20 11:20:56 -07:00
committed by GitHub
66 changed files with 3603 additions and 270 deletions
+24 -24
View File
@@ -3,56 +3,56 @@
"codex": {
"platforms": {
"macos-aarch64": {
"regex": "^codex-aarch64-apple-darwin\\.zst$",
"path": "codex"
"regex": "^codex-package-aarch64-apple-darwin\\.tar\\.zst$",
"path": "bin/codex"
},
"macos-x86_64": {
"regex": "^codex-x86_64-apple-darwin\\.zst$",
"path": "codex"
"regex": "^codex-package-x86_64-apple-darwin\\.tar\\.zst$",
"path": "bin/codex"
},
"linux-x86_64": {
"regex": "^codex-x86_64-unknown-linux-musl-bundle\\.tar\\.zst$",
"path": "codex"
"regex": "^codex-package-x86_64-unknown-linux-musl\\.tar\\.zst$",
"path": "bin/codex"
},
"linux-aarch64": {
"regex": "^codex-aarch64-unknown-linux-musl-bundle\\.tar\\.zst$",
"path": "codex"
"regex": "^codex-package-aarch64-unknown-linux-musl\\.tar\\.zst$",
"path": "bin/codex"
},
"windows-x86_64": {
"regex": "^codex-x86_64-pc-windows-msvc\\.exe\\.zst$",
"path": "codex.exe"
"regex": "^codex-package-x86_64-pc-windows-msvc\\.tar\\.zst$",
"path": "bin/codex.exe"
},
"windows-aarch64": {
"regex": "^codex-aarch64-pc-windows-msvc\\.exe\\.zst$",
"path": "codex.exe"
"regex": "^codex-package-aarch64-pc-windows-msvc\\.tar\\.zst$",
"path": "bin/codex.exe"
}
}
},
"codex-app-server": {
"platforms": {
"macos-aarch64": {
"regex": "^codex-app-server-aarch64-apple-darwin\\.zst$",
"path": "codex-app-server"
"regex": "^codex-app-server-package-aarch64-apple-darwin\\.tar\\.zst$",
"path": "bin/codex-app-server"
},
"macos-x86_64": {
"regex": "^codex-app-server-x86_64-apple-darwin\\.zst$",
"path": "codex-app-server"
"regex": "^codex-app-server-package-x86_64-apple-darwin\\.tar\\.zst$",
"path": "bin/codex-app-server"
},
"linux-x86_64": {
"regex": "^codex-app-server-x86_64-unknown-linux-musl\\.zst$",
"path": "codex-app-server"
"regex": "^codex-app-server-package-x86_64-unknown-linux-musl\\.tar\\.zst$",
"path": "bin/codex-app-server"
},
"linux-aarch64": {
"regex": "^codex-app-server-aarch64-unknown-linux-musl\\.zst$",
"path": "codex-app-server"
"regex": "^codex-app-server-package-aarch64-unknown-linux-musl\\.tar\\.zst$",
"path": "bin/codex-app-server"
},
"windows-x86_64": {
"regex": "^codex-app-server-x86_64-pc-windows-msvc\\.exe\\.zst$",
"path": "codex-app-server.exe"
"regex": "^codex-app-server-package-x86_64-pc-windows-msvc\\.tar\\.zst$",
"path": "bin/codex-app-server.exe"
},
"windows-aarch64": {
"regex": "^codex-app-server-aarch64-pc-windows-msvc\\.exe\\.zst$",
"path": "codex-app-server.exe"
"regex": "^codex-app-server-package-aarch64-pc-windows-msvc\\.tar\\.zst$",
"path": "bin/codex-app-server.exe"
}
}
},
@@ -97,9 +97,14 @@ else
python_bin="python"
fi
if ! command -v zstd >/dev/null 2>&1 && [[ -x "${repo_root}/.github/workflows/zstd" ]]; then
export PATH="${repo_root}/.github/workflows:${PATH}"
fi
mkdir -p "$archive_dir"
package_dir="${RUNNER_TEMP:-/tmp}/${archive_stem}-${target}"
archive_path="${archive_dir}/${archive_stem}-${target}.tar.gz"
gzip_archive_path="${archive_dir}/${archive_stem}-${target}.tar.gz"
zstd_archive_path="${archive_dir}/${archive_stem}-${target}.tar.zst"
rm -rf "$package_dir"
"$python_bin" "${repo_root}/scripts/build_codex_package.py" \
@@ -108,5 +113,6 @@ rm -rf "$package_dir"
--entrypoint-bin "${entrypoint_dir%/}/${entrypoint_name}${exe_suffix}" \
--cargo-profile release \
--package-dir "$package_dir" \
--archive-output "$archive_path" \
--archive-output "$gzip_archive_path" \
--archive-output "$zstd_archive_path" \
--force
+5 -3
View File
@@ -53,14 +53,16 @@ jobs:
# Passing the workflow URL directly avoids relying on old rust-v*
# branches remaining discoverable via `gh run list --branch ...`.
CODEX_VERSION=0.125.0
WORKFLOW_URL="https://github.com/openai/codex/actions/runs/24901475298"
WORKFLOW_URL="https://github.com/openai/codex/actions/runs/26131514935"
OUTPUT_DIR="${RUNNER_TEMP}"
# This reused workflow predates the standalone bwrap artifact.
# This reused workflow predates codex-package archive artifacts, so
# CI synthesizes the package layout from the older per-binary
# artifacts. Release staging must use real package archives.
python3 ./scripts/stage_npm_packages.py \
--release-version "$CODEX_VERSION" \
--workflow-url "$WORKFLOW_URL" \
--package codex \
--allow-missing-native-component bwrap \
--allow-legacy-codex-package \
--output-dir "$OUTPUT_DIR"
PACK_OUTPUT="${OUTPUT_DIR}/codex-npm-${CODEX_VERSION}.tgz"
echo "pack_output=$PACK_OUTPUT" >> "$GITHUB_OUTPUT"
+1 -1
View File
@@ -295,7 +295,7 @@ jobs:
base="$(basename "$f")"
# Skip files that are already archives (shouldn't happen, but be
# safe).
if [[ "$base" == *.tar.gz || "$base" == *.zip || "$base" == *.dmg ]]; then
if [[ "$base" == *.tar.gz || "$base" == *.tar.zst || "$base" == *.zip || "$base" == *.dmg ]]; then
continue
fi
-2
View File
@@ -1208,8 +1208,6 @@ jobs:
if: ${{ env.SIGN_MACOS == 'true' }}
run: pnpm install --frozen-lockfile
# stage_npm_packages.py requires DotSlash when staging releases.
- uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2
- name: Stage npm packages
if: ${{ env.SIGN_MACOS == 'true' }}
env:
+34 -26
View File
@@ -77,33 +77,43 @@ if (!platformPackage) {
const codexBinaryName = process.platform === "win32" ? "codex.exe" : "codex";
const localVendorRoot = path.join(__dirname, "..", "vendor");
const localBinaryPath = path.join(
localVendorRoot,
targetTriple,
"codex",
codexBinaryName,
);
const packageBinaryPath = (vendorRoot) =>
path.join(vendorRoot, targetTriple, "bin", codexBinaryName);
const legacyBinaryPath = (vendorRoot) =>
path.join(vendorRoot, targetTriple, "codex", codexBinaryName);
let vendorRoot;
try {
const packageJsonPath = require.resolve(`${platformPackage}/package.json`);
vendorRoot = path.join(path.dirname(packageJsonPath), "vendor");
} catch {
if (existsSync(localBinaryPath)) {
vendorRoot = localVendorRoot;
} else {
const packageManager = detectPackageManager();
const updateCommand =
packageManager === "bun"
? "bun install -g @openai/codex@latest"
: "npm install -g @openai/codex@latest";
throw new Error(
`Missing optional dependency ${platformPackage}. Reinstall Codex: ${updateCommand}`,
);
function resolveNativePackage(vendorRoot) {
const packageRoot = path.join(vendorRoot, targetTriple);
const binaryPath = packageBinaryPath(vendorRoot);
if (existsSync(binaryPath)) {
return {
binaryPath,
pathDir: path.join(packageRoot, "codex-path"),
};
}
const legacyPath = legacyBinaryPath(vendorRoot);
if (existsSync(legacyPath)) {
return {
binaryPath: legacyPath,
pathDir: path.join(packageRoot, "path"),
};
}
return null;
}
if (!vendorRoot) {
let nativePackage;
try {
const packageJsonPath = require.resolve(`${platformPackage}/package.json`);
nativePackage = resolveNativePackage(
path.join(path.dirname(packageJsonPath), "vendor"),
);
} catch {
nativePackage = resolveNativePackage(localVendorRoot);
}
if (!nativePackage) {
const packageManager = detectPackageManager();
const updateCommand =
packageManager === "bun"
@@ -114,8 +124,7 @@ if (!vendorRoot) {
);
}
const archRoot = path.join(vendorRoot, targetTriple);
const binaryPath = path.join(archRoot, "codex", codexBinaryName);
const { binaryPath, pathDir } = nativePackage;
// Use an asynchronous spawn instead of spawnSync so that Node is able to
// respond to signals (e.g. Ctrl-C / SIGINT) while the native binary is
@@ -159,7 +168,6 @@ function detectPackageManager() {
}
const additionalDirs = [];
const pathDir = path.join(archRoot, "path");
if (existsSync(pathDir)) {
additionalDirs.push(pathDir);
}
+4 -4
View File
@@ -11,13 +11,13 @@ example, to stage the CLI, responses proxy, and SDK packages for version `0.6.0`
--package codex-sdk
```
This downloads the native artifacts once, hydrates `vendor/` for each package, and writes
tarballs to `dist/npm/`.
This downloads the native package archive artifacts once, hydrates `vendor/` for each
package, and writes tarballs to `dist/npm/`.
When `--package codex` is provided, the staging helper builds the lightweight
`@openai/codex` meta package plus all platform-native `@openai/codex` variants
that are later published under platform-specific dist-tags.
If you need to invoke `build_npm_package.py` directly, run
`codex-cli/scripts/install_native_deps.py` first and pass `--vendor-src` pointing to the
directory that contains the populated `vendor/` tree.
`codex-cli/scripts/install_native_deps.py --component codex-package` first and pass
`--vendor-src` pointing to the directory that contains the populated `vendor/` tree.
+60 -10
View File
@@ -15,6 +15,8 @@ REPO_ROOT = CODEX_CLI_ROOT.parent
RESPONSES_API_PROXY_NPM_ROOT = REPO_ROOT / "codex-rs" / "responses-api-proxy" / "npm"
CODEX_SDK_ROOT = REPO_ROOT / "sdk" / "typescript"
CODEX_NPM_NAME = "@openai/codex"
CODEX_PACKAGE_COMPONENT = "codex-package"
CODEX_PACKAGE_ENTRIES = ("codex-package.json", "bin", "codex-resources", "codex-path")
# `npm_name` is the local optional-dependency alias consumed by `bin/codex.js`.
# The underlying package published to npm is always `@openai/codex`.
@@ -69,12 +71,12 @@ PACKAGE_EXPANSIONS: dict[str, list[str]] = {
PACKAGE_NATIVE_COMPONENTS: dict[str, list[str]] = {
"codex": [],
"codex-linux-x64": ["bwrap", "codex", "rg"],
"codex-linux-arm64": ["bwrap", "codex", "rg"],
"codex-darwin-x64": ["codex", "rg"],
"codex-darwin-arm64": ["codex", "rg"],
"codex-win32-x64": ["codex", "rg", "codex-windows-sandbox-setup", "codex-command-runner"],
"codex-win32-arm64": ["codex", "rg", "codex-windows-sandbox-setup", "codex-command-runner"],
"codex-linux-x64": [CODEX_PACKAGE_COMPONENT],
"codex-linux-arm64": [CODEX_PACKAGE_COMPONENT],
"codex-darwin-x64": [CODEX_PACKAGE_COMPONENT],
"codex-darwin-arm64": [CODEX_PACKAGE_COMPONENT],
"codex-win32-x64": [CODEX_PACKAGE_COMPONENT],
"codex-win32-arm64": [CODEX_PACKAGE_COMPONENT],
"codex-responses-api-proxy": ["codex-responses-api-proxy"],
"codex-sdk": [],
}
@@ -383,7 +385,11 @@ def copy_native_binaries(
if not vendor_src.exists():
raise RuntimeError(f"Vendor source directory not found: {vendor_src}")
components_set = {component for component in components if component in COMPONENT_DEST_DIR}
components_set = {
component
for component in components
if component == CODEX_PACKAGE_COMPONENT or component in COMPONENT_DEST_DIR
}
allow_missing_components = allow_missing_components or set()
if not components_set:
return
@@ -402,11 +408,26 @@ def copy_native_binaries(
if target_filter is not None and target_dir.name not in target_filter:
continue
dest_target_dir = vendor_dest / target_dir.name
dest_target_dir.mkdir(parents=True, exist_ok=True)
copied_targets.add(target_dir.name)
for component in components_set:
dest_target_dir = vendor_dest / target_dir.name
if CODEX_PACKAGE_COMPONENT in components_set:
validate_codex_package_dir(target_dir)
if dest_target_dir.exists():
shutil.rmtree(dest_target_dir)
dest_target_dir.mkdir(parents=True, exist_ok=True)
for entry in CODEX_PACKAGE_ENTRIES:
src = target_dir / entry
dest = dest_target_dir / entry
if src.is_dir():
shutil.copytree(src, dest)
else:
shutil.copy2(src, dest)
else:
dest_target_dir.mkdir(parents=True, exist_ok=True)
for component in components_set - {CODEX_PACKAGE_COMPONENT}:
dest_dir_name = COMPONENT_DEST_DIR.get(component)
if dest_dir_name is None:
continue
@@ -431,6 +452,35 @@ def copy_native_binaries(
raise RuntimeError(f"Missing target directories in vendor source: {missing_list}")
def validate_codex_package_dir(package_dir: Path) -> None:
is_windows = "windows" in package_dir.name
required_files = [
Path("codex-package.json"),
Path("bin") / ("codex.exe" if is_windows else "codex"),
Path("codex-path") / ("rg.exe" if is_windows else "rg"),
]
if "linux" in package_dir.name:
required_files.append(Path("codex-resources") / "bwrap")
if is_windows:
required_files.extend(
[
Path("codex-resources") / "codex-command-runner.exe",
Path("codex-resources") / "codex-windows-sandbox-setup.exe",
]
)
missing_files = [
str(relative_path)
for relative_path in required_files
if not (package_dir / relative_path).is_file()
]
if missing_files:
missing = ", ".join(missing_files)
raise RuntimeError(f"Missing files in Codex package directory {package_dir}: {missing}")
def run_npm_pack(staging_dir: Path, output_path: Path) -> Path:
output_path = output_path.resolve()
output_path.parent.mkdir(parents=True, exist_ok=True)
+185 -16
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Install Codex native binaries (Rust CLI, bwrap, and ripgrep helpers)."""
"""Install Codex package archives and native helper binaries."""
import argparse
from contextlib import contextmanager
@@ -31,6 +31,7 @@ BINARY_TARGETS = (
"x86_64-pc-windows-msvc",
"aarch64-pc-windows-msvc",
)
CODEX_PACKAGE_COMPONENT = "codex-package"
@dataclass(frozen=True)
@@ -139,11 +140,19 @@ def parse_args() -> argparse.Namespace:
"--component",
dest="components",
action="append",
choices=tuple(list(BINARY_COMPONENTS) + ["rg"]),
choices=tuple([CODEX_PACKAGE_COMPONENT, *BINARY_COMPONENTS, "rg"]),
help=(
"Limit installation to the specified components."
" May be repeated. Defaults to bwrap, codex, codex-windows-sandbox-setup,"
" codex-command-runner, and rg."
" May be repeated. Defaults to codex-package and codex-responses-api-proxy."
),
)
parser.add_argument(
"--allow-legacy-codex-package",
action="store_true",
help=(
"Allow codex-package to be synthesized from legacy per-binary artifacts "
"when package archives are missing. Intended for CI compatibility only; "
"release staging should not use this."
),
)
parser.add_argument(
@@ -165,13 +174,7 @@ def main() -> int:
vendor_dir = codex_cli_root / VENDOR_DIR_NAME
vendor_dir.mkdir(parents=True, exist_ok=True)
components = args.components or [
"bwrap",
"codex",
"codex-windows-sandbox-setup",
"codex-command-runner",
"rg",
]
components = args.components or [CODEX_PACKAGE_COMPONENT, "codex-responses-api-proxy"]
workflow_url = (args.workflow_url or DEFAULT_WORKFLOW_URL).strip()
if not workflow_url:
@@ -184,6 +187,18 @@ def main() -> int:
with tempfile.TemporaryDirectory(prefix="codex-native-artifacts-") as artifacts_dir_str:
artifacts_dir = Path(artifacts_dir_str)
_download_artifacts(workflow_id, artifacts_dir)
if CODEX_PACKAGE_COMPONENT in components:
try:
install_codex_package_archives(artifacts_dir, vendor_dir, BINARY_TARGETS)
except FileNotFoundError:
if not args.allow_legacy_codex_package:
raise
install_legacy_codex_package_layouts(
artifacts_dir,
vendor_dir,
BINARY_TARGETS,
manifest_path=RG_MANIFEST,
)
install_binary_components(
artifacts_dir,
vendor_dir,
@@ -199,6 +214,135 @@ def main() -> int:
return 0
def install_codex_package_archives(
artifacts_dir: Path,
vendor_dir: Path,
targets: Sequence[str],
) -> None:
targets = list(targets)
if not targets:
return
print("Installing Codex package archives for targets: " + ", ".join(targets))
max_workers = min(len(targets), max(1, (os.cpu_count() or 1)))
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {
executor.submit(
_install_single_codex_package_archive,
artifacts_dir,
vendor_dir,
target,
): target
for target in targets
}
for future in as_completed(futures):
installed_path = future.result()
print(f" installed {installed_path}")
def _install_single_codex_package_archive(
artifacts_dir: Path,
vendor_dir: Path,
target: str,
) -> Path:
artifact_subdir = artifact_dir_for_target(artifacts_dir, target)
archive_path = artifact_subdir / f"codex-package-{target}.tar.gz"
if not archive_path.exists():
raise FileNotFoundError(f"Expected package archive not found: {archive_path}")
dest_dir = vendor_dir / target
if dest_dir.exists():
shutil.rmtree(dest_dir)
dest_dir.mkdir(parents=True, exist_ok=True)
with tarfile.open(archive_path, "r:gz") as archive:
archive.extractall(dest_dir, filter="data")
return dest_dir
def install_legacy_codex_package_layouts(
artifacts_dir: Path,
vendor_dir: Path,
targets: Sequence[str],
*,
manifest_path: Path,
) -> None:
targets = list(targets)
print(
"Synthesizing Codex package layouts from legacy artifacts for targets: "
+ ", ".join(targets)
)
with tempfile.TemporaryDirectory(prefix="codex-legacy-package-") as legacy_vendor_dir_str:
legacy_vendor_dir = Path(legacy_vendor_dir_str)
install_binary_components(
artifacts_dir,
legacy_vendor_dir,
[
BINARY_COMPONENTS["codex"],
BINARY_COMPONENTS["bwrap"],
BINARY_COMPONENTS["codex-windows-sandbox-setup"],
BINARY_COMPONENTS["codex-command-runner"],
],
)
fetch_rg(legacy_vendor_dir, targets, manifest_path=manifest_path)
for target in targets:
dest_dir = vendor_dir / target
if dest_dir.exists():
shutil.rmtree(dest_dir)
_build_legacy_codex_package_layout(legacy_vendor_dir / target, dest_dir, target)
print(f" synthesized {dest_dir}")
def _build_legacy_codex_package_layout(
legacy_target_dir: Path,
package_dir: Path,
target: str,
) -> None:
is_windows = "windows" in target
exe_suffix = ".exe" if is_windows else ""
package_dir.mkdir(parents=True)
bin_dir = package_dir / "bin"
resources_dir = package_dir / "codex-resources"
path_dir = package_dir / "codex-path"
bin_dir.mkdir()
resources_dir.mkdir()
path_dir.mkdir()
shutil.copy2(
legacy_target_dir / "codex" / f"codex{exe_suffix}",
bin_dir / f"codex{exe_suffix}",
)
shutil.copy2(
legacy_target_dir / "path" / f"rg{exe_suffix}",
path_dir / f"rg{exe_suffix}",
)
if is_windows:
for helper in [
"codex-command-runner.exe",
"codex-windows-sandbox-setup.exe",
]:
shutil.copy2(legacy_target_dir / "codex" / helper, resources_dir / helper)
elif "linux" in target:
shutil.copy2(legacy_target_dir / "codex-resources" / "bwrap", resources_dir / "bwrap")
write_json(
package_dir / "codex-package.json",
{
"layoutVersion": 1,
"version": "unknown",
"target": target,
"variant": "codex",
"entrypoint": f"bin/codex{exe_suffix}",
"resourcesDir": "codex-resources",
"pathDir": "codex-path",
},
)
def fetch_rg(
vendor_dir: Path,
targets: Sequence[str] | None = None,
@@ -319,11 +463,8 @@ def _install_single_binary(
target: str,
component: BinaryComponent,
) -> Path:
artifact_subdir = artifacts_dir / target
archive_name = _archive_name_for_target(component.artifact_prefix, target)
archive_path = artifact_subdir / archive_name
if not archive_path.exists():
raise FileNotFoundError(f"Expected artifact not found: {archive_path}")
artifact_subdir = artifact_dir_for_target(artifacts_dir, target)
archive_path = legacy_binary_archive_path(artifact_subdir, component.artifact_prefix, target)
dest_dir = vendor_dir / target / component.dest_dir
dest_dir.mkdir(parents=True, exist_ok=True)
@@ -345,6 +486,28 @@ def _archive_name_for_target(artifact_prefix: str, target: str) -> str:
return f"{artifact_prefix}-{target}.zst"
def legacy_binary_archive_path(artifact_dir: Path, artifact_prefix: str, target: str) -> Path:
archive_names = [_archive_name_for_target(artifact_prefix, target)]
if artifact_dir.name == f"{target}-unsigned":
archive_names.append(_archive_name_for_target(artifact_prefix, f"{target}-unsigned"))
for archive_name in archive_names:
archive_path = artifact_dir / archive_name
if archive_path.exists():
return archive_path
raise FileNotFoundError(f"Expected artifact not found: {artifact_dir / archive_names[0]}")
def artifact_dir_for_target(artifacts_dir: Path, target: str) -> Path:
for artifact_name in [target, f"{target}-unsigned"]:
artifact_dir = artifacts_dir / artifact_name
if artifact_dir.is_dir():
return artifact_dir
return artifacts_dir / target
def _fetch_single_rg(
vendor_dir: Path,
target: str,
@@ -477,6 +640,12 @@ def _load_manifest(manifest_path: Path) -> dict:
return manifest
def write_json(path: Path, value: object) -> None:
with open(path, "w", encoding="utf-8") as out:
json.dump(value, out, indent=2)
out.write("\n")
if __name__ == "__main__":
import sys
+1
View File
@@ -3765,6 +3765,7 @@ dependencies = [
"codex-features",
"codex-protocol",
"codex-utils-absolute-path",
"codex-utils-output-truncation",
"codex-utils-pty",
"codex-utils-string",
"pretty_assertions",
+1
View File
@@ -176,6 +176,7 @@ pub(crate) fn server_notification_requires_delivery(notification: &ServerNotific
matches!(
notification,
ServerNotification::TurnCompleted(_)
| ServerNotification::ThreadSettingsUpdated(_)
| ServerNotification::ItemCompleted(_)
| ServerNotification::AgentMessageDelta(_)
| ServerNotification::PlanDelta(_)
@@ -64,6 +64,26 @@
},
"type": "object"
},
"ActivePermissionProfile": {
"properties": {
"extends": {
"default": null,
"description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.",
"type": [
"string",
"null"
]
},
"id": {
"description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.<id>]` profile.",
"type": "string"
}
},
"required": [
"id"
],
"type": "object"
},
"AdditionalFileSystemPermissions": {
"properties": {
"entries": {
@@ -415,6 +435,65 @@
],
"type": "object"
},
"ApprovalsReviewer": {
"description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.",
"enum": [
"user",
"auto_review",
"guardian_subagent"
],
"type": "string"
},
"AskForApproval": {
"oneOf": [
{
"enum": [
"untrusted",
"on-failure",
"on-request",
"never"
],
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"granular": {
"properties": {
"mcp_elicitations": {
"type": "boolean"
},
"request_permissions": {
"default": false,
"type": "boolean"
},
"rules": {
"type": "boolean"
},
"sandbox_approval": {
"type": "boolean"
},
"skill_approval": {
"default": false,
"type": "boolean"
}
},
"required": [
"mcp_elicitations",
"rules",
"sandbox_approval"
],
"type": "object"
}
},
"required": [
"granular"
],
"title": "GranularAskForApproval",
"type": "object"
}
]
},
"AuthMode": {
"description": "Authentication mode for OpenAI-backed providers.",
"oneOf": [
@@ -658,6 +737,22 @@
],
"type": "string"
},
"CollaborationMode": {
"description": "Collaboration mode for a Codex session.",
"properties": {
"mode": {
"$ref": "#/definitions/ModeKind"
},
"settings": {
"$ref": "#/definitions/Settings"
}
},
"required": [
"mode",
"settings"
],
"type": "object"
},
"CommandAction": {
"oneOf": [
{
@@ -2258,6 +2353,14 @@
}
]
},
"ModeKind": {
"description": "Initial collaboration mode to use when the TUI starts.",
"enum": [
"plan",
"default"
],
"type": "string"
},
"ModelRerouteReason": {
"enum": [
"highRiskCyberActivity"
@@ -2319,6 +2422,13 @@
],
"type": "object"
},
"NetworkAccess": {
"enum": [
"restricted",
"enabled"
],
"type": "string"
},
"NetworkApprovalProtocol": {
"enum": [
"http",
@@ -2402,6 +2512,14 @@
}
]
},
"Personality": {
"enum": [
"none",
"friendly",
"pragmatic"
],
"type": "string"
},
"PlanDeltaNotification": {
"description": "EXPERIMENTAL - proposed plan streaming deltas for plan items. Clients should not assume concatenated deltas match the completed plan item content.",
"properties": {
@@ -2655,6 +2773,26 @@
],
"type": "string"
},
"ReasoningSummary": {
"description": "A summary of the reasoning performed by the model. This can be useful for debugging and understanding the model's reasoning process. See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries",
"oneOf": [
{
"enum": [
"auto",
"concise",
"detailed"
],
"type": "string"
},
{
"description": "Option to disable reasoning summaries.",
"enum": [
"none"
],
"type": "string"
}
]
},
"ReasoningSummaryPartAddedNotification": {
"properties": {
"itemId": {
@@ -2807,6 +2945,105 @@
},
"type": "object"
},
"SandboxPolicy": {
"oneOf": [
{
"properties": {
"type": {
"enum": [
"dangerFullAccess"
],
"title": "DangerFullAccessSandboxPolicyType",
"type": "string"
}
},
"required": [
"type"
],
"title": "DangerFullAccessSandboxPolicy",
"type": "object"
},
{
"properties": {
"networkAccess": {
"default": false,
"type": "boolean"
},
"type": {
"enum": [
"readOnly"
],
"title": "ReadOnlySandboxPolicyType",
"type": "string"
}
},
"required": [
"type"
],
"title": "ReadOnlySandboxPolicy",
"type": "object"
},
{
"properties": {
"networkAccess": {
"allOf": [
{
"$ref": "#/definitions/NetworkAccess"
}
],
"default": "restricted"
},
"type": {
"enum": [
"externalSandbox"
],
"title": "ExternalSandboxSandboxPolicyType",
"type": "string"
}
},
"required": [
"type"
],
"title": "ExternalSandboxSandboxPolicy",
"type": "object"
},
{
"properties": {
"excludeSlashTmp": {
"default": false,
"type": "boolean"
},
"excludeTmpdirEnvVar": {
"default": false,
"type": "boolean"
},
"networkAccess": {
"default": false,
"type": "boolean"
},
"type": {
"enum": [
"workspaceWrite"
],
"title": "WorkspaceWriteSandboxPolicyType",
"type": "string"
},
"writableRoots": {
"default": [],
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
}
},
"required": [
"type"
],
"title": "WorkspaceWriteSandboxPolicy",
"type": "object"
}
]
},
"ServerRequestResolvedNotification": {
"properties": {
"requestId": {
@@ -2862,6 +3099,34 @@
}
]
},
"Settings": {
"description": "Settings for a collaboration mode.",
"properties": {
"developer_instructions": {
"type": [
"string",
"null"
]
},
"model": {
"type": "string"
},
"reasoning_effort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
]
}
},
"required": [
"model"
],
"type": "object"
},
"SkillsChangedNotification": {
"description": "Notification emitted when watched local skill files change.\n\nTreat this as an invalidation signal and re-run `skills/list` with the client's current parameters when refreshed skill metadata is needed.",
"type": "object"
@@ -4148,6 +4413,102 @@
],
"type": "object"
},
"ThreadSettings": {
"properties": {
"activePermissionProfile": {
"anyOf": [
{
"$ref": "#/definitions/ActivePermissionProfile"
},
{
"type": "null"
}
]
},
"approvalPolicy": {
"$ref": "#/definitions/AskForApproval"
},
"approvalsReviewer": {
"$ref": "#/definitions/ApprovalsReviewer"
},
"collaborationMode": {
"$ref": "#/definitions/CollaborationMode"
},
"cwd": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"effort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
]
},
"model": {
"type": "string"
},
"modelProvider": {
"type": "string"
},
"personality": {
"anyOf": [
{
"$ref": "#/definitions/Personality"
},
{
"type": "null"
}
]
},
"sandboxPolicy": {
"$ref": "#/definitions/SandboxPolicy"
},
"serviceTier": {
"type": [
"string",
"null"
]
},
"summary": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningSummary"
},
{
"type": "null"
}
]
}
},
"required": [
"approvalPolicy",
"approvalsReviewer",
"collaborationMode",
"cwd",
"model",
"modelProvider",
"sandboxPolicy"
],
"type": "object"
},
"ThreadSettingsUpdatedNotification": {
"properties": {
"threadId": {
"type": "string"
},
"threadSettings": {
"$ref": "#/definitions/ThreadSettings"
}
},
"required": [
"threadId",
"threadSettings"
],
"type": "object"
},
"ThreadSource": {
"enum": [
"user",
@@ -5089,6 +5450,26 @@
"title": "Thread/goal/clearedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"thread/settings/updated"
],
"title": "Thread/settings/updatedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ThreadSettingsUpdatedNotification"
}
},
"required": [
"method",
"params"
],
"title": "Thread/settings/updatedNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -4055,6 +4055,26 @@
"title": "Thread/goal/clearedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"thread/settings/updated"
],
"title": "Thread/settings/updatedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/ThreadSettingsUpdatedNotification"
}
},
"required": [
"method",
"params"
],
"title": "Thread/settings/updatedNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -17234,6 +17254,104 @@
"title": "ThreadSetNameResponse",
"type": "object"
},
"ThreadSettings": {
"properties": {
"activePermissionProfile": {
"anyOf": [
{
"$ref": "#/definitions/v2/ActivePermissionProfile"
},
{
"type": "null"
}
]
},
"approvalPolicy": {
"$ref": "#/definitions/v2/AskForApproval"
},
"approvalsReviewer": {
"$ref": "#/definitions/v2/ApprovalsReviewer"
},
"collaborationMode": {
"$ref": "#/definitions/v2/CollaborationMode"
},
"cwd": {
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
"effort": {
"anyOf": [
{
"$ref": "#/definitions/v2/ReasoningEffort"
},
{
"type": "null"
}
]
},
"model": {
"type": "string"
},
"modelProvider": {
"type": "string"
},
"personality": {
"anyOf": [
{
"$ref": "#/definitions/v2/Personality"
},
{
"type": "null"
}
]
},
"sandboxPolicy": {
"$ref": "#/definitions/v2/SandboxPolicy"
},
"serviceTier": {
"type": [
"string",
"null"
]
},
"summary": {
"anyOf": [
{
"$ref": "#/definitions/v2/ReasoningSummary"
},
{
"type": "null"
}
]
}
},
"required": [
"approvalPolicy",
"approvalsReviewer",
"collaborationMode",
"cwd",
"model",
"modelProvider",
"sandboxPolicy"
],
"type": "object"
},
"ThreadSettingsUpdatedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"threadId": {
"type": "string"
},
"threadSettings": {
"$ref": "#/definitions/v2/ThreadSettings"
}
},
"required": [
"threadId",
"threadSettings"
],
"title": "ThreadSettingsUpdatedNotification",
"type": "object"
},
"ThreadShellCommandParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -11407,6 +11407,26 @@
"title": "Thread/goal/clearedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"thread/settings/updated"
],
"title": "Thread/settings/updatedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ThreadSettingsUpdatedNotification"
}
},
"required": [
"method",
"params"
],
"title": "Thread/settings/updatedNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -15058,6 +15078,104 @@
"title": "ThreadSetNameResponse",
"type": "object"
},
"ThreadSettings": {
"properties": {
"activePermissionProfile": {
"anyOf": [
{
"$ref": "#/definitions/ActivePermissionProfile"
},
{
"type": "null"
}
]
},
"approvalPolicy": {
"$ref": "#/definitions/AskForApproval"
},
"approvalsReviewer": {
"$ref": "#/definitions/ApprovalsReviewer"
},
"collaborationMode": {
"$ref": "#/definitions/CollaborationMode"
},
"cwd": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"effort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
]
},
"model": {
"type": "string"
},
"modelProvider": {
"type": "string"
},
"personality": {
"anyOf": [
{
"$ref": "#/definitions/Personality"
},
{
"type": "null"
}
]
},
"sandboxPolicy": {
"$ref": "#/definitions/SandboxPolicy"
},
"serviceTier": {
"type": [
"string",
"null"
]
},
"summary": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningSummary"
},
{
"type": "null"
}
]
}
},
"required": [
"approvalPolicy",
"approvalsReviewer",
"collaborationMode",
"cwd",
"model",
"modelProvider",
"sandboxPolicy"
],
"type": "object"
},
"ThreadSettingsUpdatedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"threadId": {
"type": "string"
},
"threadSettings": {
"$ref": "#/definitions/ThreadSettings"
}
},
"required": [
"threadId",
"threadSettings"
],
"title": "ThreadSettingsUpdatedNotification",
"type": "object"
},
"ThreadShellCommandParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -0,0 +1,381 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"ActivePermissionProfile": {
"properties": {
"extends": {
"default": null,
"description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.",
"type": [
"string",
"null"
]
},
"id": {
"description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.<id>]` profile.",
"type": "string"
}
},
"required": [
"id"
],
"type": "object"
},
"ApprovalsReviewer": {
"description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.",
"enum": [
"user",
"auto_review",
"guardian_subagent"
],
"type": "string"
},
"AskForApproval": {
"oneOf": [
{
"enum": [
"untrusted",
"on-failure",
"on-request",
"never"
],
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"granular": {
"properties": {
"mcp_elicitations": {
"type": "boolean"
},
"request_permissions": {
"default": false,
"type": "boolean"
},
"rules": {
"type": "boolean"
},
"sandbox_approval": {
"type": "boolean"
},
"skill_approval": {
"default": false,
"type": "boolean"
}
},
"required": [
"mcp_elicitations",
"rules",
"sandbox_approval"
],
"type": "object"
}
},
"required": [
"granular"
],
"title": "GranularAskForApproval",
"type": "object"
}
]
},
"CollaborationMode": {
"description": "Collaboration mode for a Codex session.",
"properties": {
"mode": {
"$ref": "#/definitions/ModeKind"
},
"settings": {
"$ref": "#/definitions/Settings"
}
},
"required": [
"mode",
"settings"
],
"type": "object"
},
"ModeKind": {
"description": "Initial collaboration mode to use when the TUI starts.",
"enum": [
"plan",
"default"
],
"type": "string"
},
"NetworkAccess": {
"enum": [
"restricted",
"enabled"
],
"type": "string"
},
"Personality": {
"enum": [
"none",
"friendly",
"pragmatic"
],
"type": "string"
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"type": "string"
},
"ReasoningSummary": {
"description": "A summary of the reasoning performed by the model. This can be useful for debugging and understanding the model's reasoning process. See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries",
"oneOf": [
{
"enum": [
"auto",
"concise",
"detailed"
],
"type": "string"
},
{
"description": "Option to disable reasoning summaries.",
"enum": [
"none"
],
"type": "string"
}
]
},
"SandboxPolicy": {
"oneOf": [
{
"properties": {
"type": {
"enum": [
"dangerFullAccess"
],
"title": "DangerFullAccessSandboxPolicyType",
"type": "string"
}
},
"required": [
"type"
],
"title": "DangerFullAccessSandboxPolicy",
"type": "object"
},
{
"properties": {
"networkAccess": {
"default": false,
"type": "boolean"
},
"type": {
"enum": [
"readOnly"
],
"title": "ReadOnlySandboxPolicyType",
"type": "string"
}
},
"required": [
"type"
],
"title": "ReadOnlySandboxPolicy",
"type": "object"
},
{
"properties": {
"networkAccess": {
"allOf": [
{
"$ref": "#/definitions/NetworkAccess"
}
],
"default": "restricted"
},
"type": {
"enum": [
"externalSandbox"
],
"title": "ExternalSandboxSandboxPolicyType",
"type": "string"
}
},
"required": [
"type"
],
"title": "ExternalSandboxSandboxPolicy",
"type": "object"
},
{
"properties": {
"excludeSlashTmp": {
"default": false,
"type": "boolean"
},
"excludeTmpdirEnvVar": {
"default": false,
"type": "boolean"
},
"networkAccess": {
"default": false,
"type": "boolean"
},
"type": {
"enum": [
"workspaceWrite"
],
"title": "WorkspaceWriteSandboxPolicyType",
"type": "string"
},
"writableRoots": {
"default": [],
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
}
},
"required": [
"type"
],
"title": "WorkspaceWriteSandboxPolicy",
"type": "object"
}
]
},
"Settings": {
"description": "Settings for a collaboration mode.",
"properties": {
"developer_instructions": {
"type": [
"string",
"null"
]
},
"model": {
"type": "string"
},
"reasoning_effort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
]
}
},
"required": [
"model"
],
"type": "object"
},
"ThreadSettings": {
"properties": {
"activePermissionProfile": {
"anyOf": [
{
"$ref": "#/definitions/ActivePermissionProfile"
},
{
"type": "null"
}
]
},
"approvalPolicy": {
"$ref": "#/definitions/AskForApproval"
},
"approvalsReviewer": {
"$ref": "#/definitions/ApprovalsReviewer"
},
"collaborationMode": {
"$ref": "#/definitions/CollaborationMode"
},
"cwd": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"effort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
]
},
"model": {
"type": "string"
},
"modelProvider": {
"type": "string"
},
"personality": {
"anyOf": [
{
"$ref": "#/definitions/Personality"
},
{
"type": "null"
}
]
},
"sandboxPolicy": {
"$ref": "#/definitions/SandboxPolicy"
},
"serviceTier": {
"type": [
"string",
"null"
]
},
"summary": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningSummary"
},
{
"type": "null"
}
]
}
},
"required": [
"approvalPolicy",
"approvalsReviewer",
"collaborationMode",
"cwd",
"model",
"modelProvider",
"sandboxPolicy"
],
"type": "object"
}
},
"properties": {
"threadId": {
"type": "string"
},
"threadSettings": {
"$ref": "#/definitions/ThreadSettings"
}
},
"required": [
"threadId",
"threadSettings"
],
"title": "ThreadSettingsUpdatedNotification",
"type": "object"
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,14 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
import type { CollaborationMode } from "../CollaborationMode";
import type { Personality } from "../Personality";
import type { ReasoningEffort } from "../ReasoningEffort";
import type { ReasoningSummary } from "../ReasoningSummary";
import type { ActivePermissionProfile } from "./ActivePermissionProfile";
import type { ApprovalsReviewer } from "./ApprovalsReviewer";
import type { AskForApproval } from "./AskForApproval";
import type { SandboxPolicy } from "./SandboxPolicy";
export type ThreadSettings = { cwd: AbsolutePathBuf, approvalPolicy: AskForApproval, approvalsReviewer: ApprovalsReviewer, sandboxPolicy: SandboxPolicy, activePermissionProfile: ActivePermissionProfile | null, model: string, modelProvider: string, serviceTier: string | null, effort: ReasoningEffort | null, summary: ReasoningSummary | null, collaborationMode: CollaborationMode, personality: Personality | 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 { ThreadSettings } from "./ThreadSettings";
export type ThreadSettingsUpdatedNotification = { threadId: string, threadSettings: ThreadSettings, };
@@ -397,6 +397,8 @@ export type { ThreadRollbackParams } from "./ThreadRollbackParams";
export type { ThreadRollbackResponse } from "./ThreadRollbackResponse";
export type { ThreadSetNameParams } from "./ThreadSetNameParams";
export type { ThreadSetNameResponse } from "./ThreadSetNameResponse";
export type { ThreadSettings } from "./ThreadSettings";
export type { ThreadSettingsUpdatedNotification } from "./ThreadSettingsUpdatedNotification";
export type { ThreadShellCommandParams } from "./ThreadShellCommandParams";
export type { ThreadShellCommandResponse } from "./ThreadShellCommandResponse";
export type { ThreadSortKey } from "./ThreadSortKey";
@@ -517,6 +517,13 @@ client_request_definitions! {
serialization: thread_id(params.thread_id),
response: v2::ThreadMetadataUpdateResponse,
},
#[experimental("thread/settings/update")]
ThreadSettingsUpdate => "thread/settings/update" {
params: v2::ThreadSettingsUpdateParams,
inspect_params: true,
serialization: thread_id(params.thread_id),
response: v2::ThreadSettingsUpdateResponse,
},
#[experimental("thread/memoryMode/set")]
ThreadMemoryModeSet => "thread/memoryMode/set" {
params: v2::ThreadMemoryModeSetParams,
@@ -1470,6 +1477,8 @@ server_notification_definitions! {
ThreadGoalUpdated => "thread/goal/updated" (v2::ThreadGoalUpdatedNotification),
#[experimental("thread/goal/cleared")]
ThreadGoalCleared => "thread/goal/cleared" (v2::ThreadGoalClearedNotification),
#[experimental("thread/settings/updated")]
ThreadSettingsUpdated => "thread/settings/updated" (v2::ThreadSettingsUpdatedNotification),
ThreadTokenUsageUpdated => "thread/tokenUsage/updated" (v2::ThreadTokenUsageUpdatedNotification),
TurnStarted => "turn/started" (v2::TurnStartedNotification),
HookStarted => "hook/started" (v2::HookStartedNotification),
@@ -3094,6 +3103,40 @@ mod tests {
);
}
#[test]
fn thread_settings_updated_notification_is_marked_experimental() {
let notification =
ServerNotification::ThreadSettingsUpdated(v2::ThreadSettingsUpdatedNotification {
thread_id: "thr_123".to_string(),
thread_settings: v2::ThreadSettings {
cwd: absolute_path("/tmp/repo"),
approval_policy: v2::AskForApproval::Never,
approvals_reviewer: v2::ApprovalsReviewer::User,
sandbox_policy: v2::SandboxPolicy::DangerFullAccess,
active_permission_profile: None,
model: "gpt-5.4".to_string(),
model_provider: "openai".to_string(),
service_tier: None,
effort: None,
summary: None,
collaboration_mode: codex_protocol::config_types::CollaborationMode {
mode: codex_protocol::config_types::ModeKind::Default,
settings: codex_protocol::config_types::Settings {
model: "gpt-5.4".to_string(),
reasoning_effort: None,
developer_instructions: None,
},
},
personality: None,
},
});
assert_eq!(
crate::experimental_api::ExperimentalApi::experimental_reason(&notification),
Some("thread/settings/updated")
);
}
#[test]
fn thread_realtime_started_notification_is_marked_experimental() {
let notification =
@@ -3580,6 +3580,77 @@ fn turn_start_params_preserve_explicit_null_service_tier() {
assert_eq!(serialized_without_override.get("serviceTier"), None);
}
#[test]
fn thread_settings_update_params_preserve_explicit_null_service_tier() {
let params: ThreadSettingsUpdateParams = serde_json::from_value(json!({
"threadId": "thread_123",
"serviceTier": null
}))
.expect("params should deserialize");
assert_eq!(params.service_tier, Some(None));
let serialized = serde_json::to_value(&params).expect("params should serialize");
assert_eq!(
serialized.get("serviceTier"),
Some(&serde_json::Value::Null)
);
let without_override = ThreadSettingsUpdateParams {
thread_id: "thread_123".to_string(),
service_tier: None,
..Default::default()
};
let serialized_without_override =
serde_json::to_value(&without_override).expect("params should serialize");
assert_eq!(serialized_without_override.get("serviceTier"), None);
}
#[test]
fn thread_settings_update_params_preserve_field_level_experimental_gates() {
let permissions = ThreadSettingsUpdateParams {
thread_id: "thread_123".to_string(),
permissions: Some(":workspace".to_string()),
..Default::default()
};
assert_eq!(
crate::experimental_api::ExperimentalApi::experimental_reason(&permissions),
Some("thread/settings/update.permissions")
);
let granular_approval = ThreadSettingsUpdateParams {
thread_id: "thread_123".to_string(),
approval_policy: Some(AskForApproval::Granular {
sandbox_approval: true,
rules: true,
skill_approval: false,
request_permissions: false,
mcp_elicitations: true,
}),
..Default::default()
};
assert_eq!(
crate::experimental_api::ExperimentalApi::experimental_reason(&granular_approval),
Some("askForApproval.granular")
);
let collaboration_mode = ThreadSettingsUpdateParams {
thread_id: "thread_123".to_string(),
collaboration_mode: Some(codex_protocol::config_types::CollaborationMode {
mode: codex_protocol::config_types::ModeKind::Plan,
settings: codex_protocol::config_types::Settings {
model: "mock-model".to_string(),
reasoning_effort: None,
developer_instructions: None,
},
}),
..Default::default()
};
assert_eq!(
crate::experimental_api::ExperimentalApi::experimental_reason(&collaboration_mode),
Some("thread/settings/update.collaborationMode")
);
}
#[test]
fn turn_start_params_round_trip_environments() {
let cwd = test_absolute_path();
@@ -11,7 +11,9 @@ use super::TurnEnvironmentParams;
use super::TurnItemsView;
use super::shared::v2_enum_from_core;
use codex_experimental_api_macros::ExperimentalApi;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::ThreadGoalStatus as CoreThreadGoalStatus;
@@ -219,6 +221,93 @@ pub struct ThreadStartResponse {
pub reasoning_effort: Option<ReasoningEffort>,
}
#[derive(
Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi,
)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadSettingsUpdateParams {
pub thread_id: String,
/// Override the working directory for subsequent turns.
#[ts(optional = nullable)]
pub cwd: Option<PathBuf>,
/// Override the approval policy for subsequent turns.
#[experimental(nested)]
#[ts(optional = nullable)]
pub approval_policy: Option<AskForApproval>,
/// Override where approval requests are routed for subsequent turns.
#[ts(optional = nullable)]
pub approvals_reviewer: Option<ApprovalsReviewer>,
/// Override the sandbox policy for subsequent turns.
#[ts(optional = nullable)]
pub sandbox_policy: Option<SandboxPolicy>,
/// Select a named permissions profile id for subsequent turns. Cannot be
/// combined with `sandboxPolicy`.
#[experimental("thread/settings/update.permissions")]
#[ts(optional = nullable)]
pub permissions: Option<String>,
/// Override the model for subsequent turns.
#[ts(optional = nullable)]
pub model: Option<String>,
/// Override the service tier for subsequent turns. `null` clears the
/// current service tier; omission leaves it unchanged.
#[serde(
default,
deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option",
serialize_with = "crate::protocol::serde_helpers::serialize_double_option",
skip_serializing_if = "Option::is_none"
)]
#[ts(optional = nullable)]
pub service_tier: Option<Option<String>>,
/// Override the reasoning effort for subsequent turns.
#[ts(optional = nullable)]
pub effort: Option<ReasoningEffort>,
/// Override the reasoning summary for subsequent turns.
#[ts(optional = nullable)]
pub summary: Option<ReasoningSummary>,
/// EXPERIMENTAL - Set a pre-set collaboration mode for subsequent turns.
///
/// For `collaboration_mode.settings.developer_instructions`, `null` means
/// "use the built-in instructions for the selected mode".
#[experimental("thread/settings/update.collaborationMode")]
#[ts(optional = nullable)]
pub collaboration_mode: Option<CollaborationMode>,
/// Override the personality for subsequent turns.
#[ts(optional = nullable)]
pub personality: Option<Personality>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadSettingsUpdateResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadSettings {
pub cwd: AbsolutePathBuf,
pub approval_policy: AskForApproval,
pub approvals_reviewer: ApprovalsReviewer,
pub sandbox_policy: SandboxPolicy,
pub active_permission_profile: Option<ActivePermissionProfile>,
pub model: String,
pub model_provider: String,
pub service_tier: Option<String>,
pub effort: Option<ReasoningEffort>,
pub summary: Option<ReasoningSummary>,
pub collaboration_mode: CollaborationMode,
pub personality: Option<Personality>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadSettingsUpdatedNotification {
pub thread_id: String,
pub thread_settings: ThreadSettings,
}
#[derive(
Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi,
)]
+2
View File
@@ -140,6 +140,7 @@ Example with notification opt-out:
- `thread/turns/list` — experimental; page through a stored thread’s turn history without resuming it; supports cursor-based pagination with `sortDirection`, `itemsView`, `nextCursor`, and `backwardsCursor`.
- `thread/turns/items/list` — experimental; reserved for paging full items for one turn. The API shape is present, but app-server currently returns an unsupported-method JSON-RPC error.
- `thread/metadata/update` — patch stored thread metadata in sqlite; currently supports updating persisted `gitInfo` fields and returns the refreshed `thread`.
- `thread/settings/update` — experimental; queue a partial update to a loaded thread’s next-turn settings without starting a turn or adding transcript items. Omitted fields leave settings unchanged; `serviceTier: null` clears the tier; `sandboxPolicy` and `permissions` cannot be combined. Returns `{}` when the update is accepted and emits `thread/settings/updated` with the full effective settings only if they actually change. `turn/start` settings overrides emit the same notification when they change the stored settings.
- `thread/memoryMode/set` — experimental; set a thread’s persisted memory eligibility to `"enabled"` or `"disabled"` for either a loaded thread or a stored rollout; returns `{}` on success.
- `memory/reset` — experimental; clear the current `CODEX_HOME/memories` directory and reset persisted memory stage data in sqlite while preserving existing thread memory modes; returns `{}` on success.
- `thread/goal/set` — create or update the single persisted goal for a materialized thread; returns the current goal and emits `thread/goal/updated`.
@@ -147,6 +148,7 @@ Example with notification opt-out:
- `thread/goal/clear` — clear the current persisted goal for a materialized thread; returns whether a goal was removed and emits `thread/goal/cleared` when state changes.
- `thread/goal/updated` — notification emitted whenever a thread goal changes; includes the full current goal.
- `thread/goal/cleared` — notification emitted whenever a thread goal is removed.
- `thread/settings/updated` — experimental notification emitted to subscribed clients when a loaded thread’s effective next-turn settings change; includes `threadId` and the full `threadSettings`.
- `thread/status/changed` — notification emitted when a loaded thread’s status changes (`threadId` + new `status`).
- `thread/archive` — move a thread’s rollout file into the archived directory and attempt to move any spawned descendant thread rollout files; returns `{}` on success and emits `thread/archived` for each archived thread.
- `thread/unsubscribe` — unsubscribe this connection from thread turn/item events. If this was the last subscriber, the server keeps the thread loaded and unloads it only after it has had no subscribers and no thread activity for 30 minutes, then emits `thread/closed`.
@@ -4,6 +4,7 @@ use crate::outgoing_message::ClientRequestResult;
use crate::outgoing_message::ThreadScopedOutgoingMessageSender;
use crate::request_processors::populate_thread_turns_from_history;
use crate::request_processors::thread_from_stored_thread;
use crate::request_processors::thread_settings_from_core_snapshot;
use crate::server_request_error::is_turn_transition_server_request_error;
use crate::thread_state::ThreadState;
use crate::thread_state::TurnSummary;
@@ -60,6 +61,7 @@ use codex_app_server_protocol::ThreadRealtimeStartedNotification;
use codex_app_server_protocol::ThreadRealtimeTranscriptDeltaNotification;
use codex_app_server_protocol::ThreadRealtimeTranscriptDoneNotification;
use codex_app_server_protocol::ThreadRollbackResponse;
use codex_app_server_protocol::ThreadSettingsUpdatedNotification;
use codex_app_server_protocol::ThreadStatus;
use codex_app_server_protocol::ThreadTokenUsage;
use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification;
@@ -1200,6 +1202,24 @@ pub(crate) async fn apply_bespoke_event_handling(
))
.await;
}
EventMsg::ThreadSettingsApplied(thread_settings_event) => {
let thread_settings =
thread_settings_from_core_snapshot(thread_settings_event.thread_settings);
let changed = {
let mut state = thread_state.lock().await;
state.note_thread_settings(thread_settings.clone())
};
if changed {
outgoing
.send_server_notification(ServerNotification::ThreadSettingsUpdated(
ThreadSettingsUpdatedNotification {
thread_id: conversation_id.to_string(),
thread_settings,
},
))
.await;
}
}
EventMsg::TurnDiff(turn_diff_event) => {
handle_turn_diff(conversation_id, &event_turn_id, turn_diff_event, &outgoing).await;
}
+4 -1
View File
@@ -102,7 +102,10 @@ pub const DEFAULT_IN_PROCESS_CHANNEL_CAPACITY: usize = CHANNEL_CAPACITY;
type PendingClientRequestResponse = std::result::Result<Result, JSONRPCErrorError>;
fn server_notification_requires_delivery(notification: &ServerNotification) -> bool {
matches!(notification, ServerNotification::TurnCompleted(_))
matches!(
notification,
ServerNotification::TurnCompleted(_) | ServerNotification::ThreadSettingsUpdated(_)
)
}
/// Input needed to start an in-process app-server runtime.
@@ -1033,6 +1033,11 @@ impl MessageProcessor {
ClientRequest::ThreadMetadataUpdate { params, .. } => {
self.thread_processor.thread_metadata_update(params).await
}
ClientRequest::ThreadSettingsUpdate { params, .. } => {
self.turn_processor
.thread_settings_update(&request_id, params)
.await
}
ClientRequest::ThreadMemoryModeSet { params, .. } => {
self.thread_processor.thread_memory_mode_set(params).await
}
@@ -216,6 +216,9 @@ use codex_app_server_protocol::ThreadResumeResponse;
use codex_app_server_protocol::ThreadRollbackParams;
use codex_app_server_protocol::ThreadSetNameParams;
use codex_app_server_protocol::ThreadSetNameResponse;
use codex_app_server_protocol::ThreadSettings;
use codex_app_server_protocol::ThreadSettingsUpdateParams;
use codex_app_server_protocol::ThreadSettingsUpdateResponse;
use codex_app_server_protocol::ThreadShellCommandParams;
use codex_app_server_protocol::ThreadShellCommandResponse;
use codex_app_server_protocol::ThreadSortKey;
@@ -353,6 +356,7 @@ use codex_protocol::ThreadId;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::ForcedLoginMethod;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::TrustLevel;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::dynamic_tools::DynamicToolSpec as CoreDynamicToolSpec;
@@ -364,6 +368,7 @@ use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ReasoningEffort;
#[cfg(test)]
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::protocol::AgentStatus;
@@ -516,6 +521,8 @@ pub(crate) use self::thread_processor::thread_from_stored_thread;
pub(crate) use self::thread_summary::read_summary_from_rollout;
#[cfg(test)]
pub(crate) use self::thread_summary::summary_to_thread;
pub(crate) use self::thread_summary::thread_settings_from_config_snapshot;
pub(crate) use self::thread_summary::thread_settings_from_core_snapshot;
pub(crate) fn build_api_turns_from_rollout_items(items: &[RolloutItem]) -> Vec<Turn> {
let mut builder = ThreadHistoryBuilder::new();
@@ -237,12 +237,19 @@ pub(super) async fn ensure_listener_task_running(
&environments,
)
.await;
let thread_settings_baseline =
thread_settings_from_config_snapshot(&conversation.config_snapshot().await);
let (mut listener_command_rx, listener_generation) = {
let mut thread_state = thread_state.lock().await;
if thread_state.listener_matches(&conversation) {
return Ok(());
}
thread_state.set_listener(cancel_tx, &conversation, watch_registration)
thread_state.set_listener(
cancel_tx,
&conversation,
watch_registration,
thread_settings_baseline,
)
};
let ListenerTaskContext {
outgoing,
@@ -169,13 +169,13 @@ pub(super) fn with_thread_spawn_agent_metadata(
}
}
pub(super) fn thread_response_active_permission_profile(
pub(crate) fn thread_response_active_permission_profile(
active_permission_profile: Option<codex_protocol::models::ActivePermissionProfile>,
) -> Option<codex_app_server_protocol::ActivePermissionProfile> {
active_permission_profile.map(Into::into)
}
pub(super) fn thread_response_sandbox_policy(
pub(crate) fn thread_response_sandbox_policy(
permission_profile: &codex_protocol::models::PermissionProfile,
cwd: &Path,
) -> codex_app_server_protocol::SandboxPolicy {
@@ -189,6 +189,54 @@ pub(super) fn thread_response_sandbox_policy(
sandbox_policy.into()
}
pub(crate) fn thread_settings_from_config_snapshot(
config_snapshot: &ThreadConfigSnapshot,
) -> ThreadSettings {
ThreadSettings {
cwd: config_snapshot.cwd.clone(),
approval_policy: config_snapshot.approval_policy.into(),
approvals_reviewer: config_snapshot.approvals_reviewer.into(),
sandbox_policy: thread_response_sandbox_policy(
&config_snapshot.permission_profile,
config_snapshot.cwd.as_path(),
),
active_permission_profile: thread_response_active_permission_profile(
config_snapshot.active_permission_profile.clone(),
),
model: config_snapshot.model.clone(),
model_provider: config_snapshot.model_provider_id.clone(),
service_tier: config_snapshot.service_tier.clone(),
effort: config_snapshot.reasoning_effort,
summary: config_snapshot.reasoning_summary,
collaboration_mode: config_snapshot.collaboration_mode.clone(),
personality: config_snapshot.personality,
}
}
pub(crate) fn thread_settings_from_core_snapshot(
snapshot: codex_protocol::protocol::ThreadSettingsSnapshot,
) -> ThreadSettings {
ThreadSettings {
sandbox_policy: thread_response_sandbox_policy(
&snapshot.permission_profile,
snapshot.cwd.as_path(),
),
cwd: snapshot.cwd,
approval_policy: snapshot.approval_policy.into(),
approvals_reviewer: snapshot.approvals_reviewer.into(),
active_permission_profile: thread_response_active_permission_profile(
snapshot.active_permission_profile,
),
model: snapshot.model,
model_provider: snapshot.model_provider_id,
service_tier: snapshot.service_tier,
effort: snapshot.reasoning_effort,
summary: snapshot.reasoning_summary,
collaboration_mode: snapshot.collaboration_mode,
personality: snapshot.personality,
}
}
#[cfg(test)]
fn parse_datetime(timestamp: Option<&str>) -> Option<DateTime<Utc>> {
timestamp.and_then(|ts| {
@@ -30,6 +30,22 @@ fn resolve_runtime_workspace_roots(
resolved_roots
}
struct ThreadSettingsBuildParams {
method: &'static str,
cwd: Option<PathBuf>,
runtime_workspace_roots: Option<Vec<PathBuf>>,
approval_policy: Option<codex_app_server_protocol::AskForApproval>,
approvals_reviewer: Option<codex_app_server_protocol::ApprovalsReviewer>,
sandbox_policy: Option<codex_app_server_protocol::SandboxPolicy>,
permissions: Option<String>,
model: Option<String>,
service_tier: Option<Option<String>>,
effort: Option<ReasoningEffort>,
summary: Option<ReasoningSummary>,
collaboration_mode: Option<CollaborationMode>,
personality: Option<Personality>,
}
impl TurnRequestProcessor {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
@@ -88,6 +104,16 @@ impl TurnRequestProcessor {
.map(|response| Some(response.into()))
}
pub(crate) async fn thread_settings_update(
&self,
request_id: &ConnectionRequestId,
params: ThreadSettingsUpdateParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.thread_settings_update_inner(request_id, params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn turn_steer(
&self,
request_id: &ConnectionRequestId,
@@ -199,7 +225,7 @@ impl TurnRequestProcessor {
Ok((thread_id, thread))
}
fn normalize_turn_start_collaboration_mode(
fn normalize_collaboration_mode(
&self,
mut collaboration_mode: CollaborationMode,
) -> CollaborationMode {
@@ -357,9 +383,6 @@ impl TurnRequestProcessor {
self.track_error_response(&request_id, error, /*error_type*/ None);
})?;
let collaboration_mode = params
.collaboration_mode
.map(|mode| self.normalize_turn_start_collaboration_mode(mode));
let environment_selections = self.parse_environment_selections(params.environments)?;
// Map v2 input items to core input items.
@@ -369,156 +392,26 @@ impl TurnRequestProcessor {
.map(V2UserInput::into_core)
.collect();
let turn_has_input = !mapped_items.is_empty();
let runtime_workspace_roots_request = params.runtime_workspace_roots.clone();
let snapshot = if params.permissions.is_some() || runtime_workspace_roots_request.is_some()
{
Some(thread.config_snapshot().await)
} else {
None
};
let has_any_overrides = params.cwd.is_some()
|| runtime_workspace_roots_request.is_some()
|| params.approval_policy.is_some()
|| params.approvals_reviewer.is_some()
|| params.sandbox_policy.is_some()
|| params.permissions.is_some()
|| params.model.is_some()
|| params.service_tier.is_some()
|| params.effort.is_some()
|| params.summary.is_some()
|| collaboration_mode.is_some()
|| params.personality.is_some();
if params.sandbox_policy.is_some() && params.permissions.is_some() {
return Err(invalid_request(
"`permissions` cannot be combined with `sandboxPolicy`",
));
}
let cwd = params.cwd;
let runtime_workspace_roots = if let Some(workspace_roots) =
runtime_workspace_roots_request.clone()
{
let Some(snapshot) = snapshot.as_ref() else {
return Err(internal_error(
"turn/start runtime workspace roots missing thread snapshot",
));
};
let base_cwd = cwd
.as_ref()
.map(|cwd| AbsolutePathBuf::resolve_path_against_base(cwd, snapshot.cwd.as_path()))
.unwrap_or_else(|| snapshot.cwd.clone());
Some(resolve_runtime_workspace_roots(workspace_roots, &base_cwd))
} else {
None
};
let approval_policy = params.approval_policy.map(AskForApproval::to_core);
let approvals_reviewer = params
.approvals_reviewer
.map(codex_app_server_protocol::ApprovalsReviewer::to_core);
let sandbox_policy = params.sandbox_policy.map(|p| p.to_core());
let (permission_profile, active_permission_profile, profile_workspace_roots) =
if let Some(permissions) = params.permissions {
let Some(snapshot) = snapshot.as_ref() else {
return Err(internal_error(
"turn/start permission selection missing thread snapshot",
));
};
let overrides = ConfigOverrides {
cwd: cwd.clone(),
workspace_roots: Some(runtime_workspace_roots_request.clone().unwrap_or_else(
|| {
snapshot
.workspace_roots
.iter()
.map(AbsolutePathBuf::to_path_buf)
.collect()
},
)),
default_permissions: Some(permissions),
codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(),
main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(),
..Default::default()
};
let config = self
.config_manager
.load_for_cwd(
/*request_overrides*/ None,
overrides,
Some(snapshot.cwd.to_path_buf()),
)
.await
.map_err(|err| config_load_error(&err))?;
// Startup config is allowed to fall back when requirements
// disallow a configured profile. An explicit turn request
// is different: reject it before accepting user input.
if let Some(warning) = config.startup_warnings.iter().find(|warning| {
warning.contains("Configured value for `permission_profile` is disallowed")
}) {
return Err(invalid_request(format!(
"invalid thread settings override: {warning}"
)));
}
(
Some(config.permissions.permission_profile().clone()),
config.permissions.active_permission_profile(),
Some(config.permissions.profile_workspace_roots().to_vec()),
)
} else {
(None, None, None)
};
let model = params.model;
let effort = params.effort.map(Some);
let summary = params.summary;
let service_tier = params.service_tier;
let personality = params.personality;
// If any overrides are provided, validate them synchronously so the
// request can fail before accepting user input. The actual update is
// still queued together with the input below to preserve submission order.
if has_any_overrides {
thread
.preview_thread_settings_overrides(CodexThreadSettingsOverrides {
cwd: cwd.clone(),
workspace_roots: runtime_workspace_roots.clone(),
approval_policy,
approvals_reviewer,
sandbox_policy: sandbox_policy.clone(),
permission_profile: permission_profile.clone(),
active_permission_profile: active_permission_profile.clone(),
profile_workspace_roots: profile_workspace_roots.clone(),
windows_sandbox_level: None,
model: model.clone(),
effort,
summary,
service_tier: service_tier.clone(),
collaboration_mode: collaboration_mode.clone(),
personality,
})
.await
.map_err(|err| {
invalid_request(format!("invalid thread settings override: {err}"))
})?;
}
let thread_settings = codex_protocol::protocol::ThreadSettingsOverrides {
cwd,
workspace_roots: runtime_workspace_roots,
profile_workspace_roots,
approval_policy,
approvals_reviewer,
sandbox_policy,
permission_profile,
active_permission_profile,
windows_sandbox_level: None,
model,
effort,
summary,
service_tier,
collaboration_mode,
personality,
};
let thread_settings = self
.build_thread_settings_overrides(
thread.as_ref(),
ThreadSettingsBuildParams {
method: "turn/start",
cwd: params.cwd,
runtime_workspace_roots: params.runtime_workspace_roots,
approval_policy: params.approval_policy,
approvals_reviewer: params.approvals_reviewer,
sandbox_policy: params.sandbox_policy,
permissions: params.permissions,
model: params.model,
service_tier: params.service_tier,
effort: params.effort,
summary: params.summary,
collaboration_mode: params.collaboration_mode,
personality: params.personality,
},
)
.await?;
// Start the turn by submitting the user input. Return its submission id as turn_id.
let turn_op = Op::UserInput {
@@ -566,6 +459,215 @@ impl TurnRequestProcessor {
Ok(TurnStartResponse { turn })
}
async fn build_thread_settings_overrides(
&self,
thread: &CodexThread,
params: ThreadSettingsBuildParams,
) -> Result<codex_protocol::protocol::ThreadSettingsOverrides, JSONRPCErrorError> {
let ThreadSettingsBuildParams {
method,
cwd,
runtime_workspace_roots,
approval_policy,
approvals_reviewer,
sandbox_policy,
permissions,
model,
service_tier,
effort,
summary,
collaboration_mode,
personality,
} = params;
if sandbox_policy.is_some() && permissions.is_some() {
return Err(invalid_request(
"`permissions` cannot be combined with `sandboxPolicy`",
));
}
let collaboration_mode =
collaboration_mode.map(|mode| self.normalize_collaboration_mode(mode));
let runtime_workspace_roots_request = runtime_workspace_roots;
// `thread/settings/update` only acknowledges that the update was queued.
// Clients that send dependent partial updates should wait for
// `thread/settings/updated` or combine the fields in one request.
let snapshot = if permissions.is_some() || runtime_workspace_roots_request.is_some() {
Some(thread.config_snapshot().await)
} else {
None
};
let has_any_overrides = cwd.is_some()
|| runtime_workspace_roots_request.is_some()
|| approval_policy.is_some()
|| approvals_reviewer.is_some()
|| sandbox_policy.is_some()
|| permissions.is_some()
|| model.is_some()
|| service_tier.is_some()
|| effort.is_some()
|| summary.is_some()
|| collaboration_mode.is_some()
|| personality.is_some();
let runtime_workspace_roots = if let Some(workspace_roots) =
runtime_workspace_roots_request.clone()
{
let Some(snapshot) = snapshot.as_ref() else {
return Err(internal_error(format!(
"{method} runtime workspace roots missing thread snapshot"
)));
};
let base_cwd = cwd
.as_ref()
.map(|cwd| AbsolutePathBuf::resolve_path_against_base(cwd, snapshot.cwd.as_path()))
.unwrap_or_else(|| snapshot.cwd.clone());
Some(resolve_runtime_workspace_roots(workspace_roots, &base_cwd))
} else {
None
};
let approval_policy =
approval_policy.map(codex_app_server_protocol::AskForApproval::to_core);
let approvals_reviewer =
approvals_reviewer.map(codex_app_server_protocol::ApprovalsReviewer::to_core);
let sandbox_policy = sandbox_policy.map(|policy| policy.to_core());
let (permission_profile, active_permission_profile, profile_workspace_roots) =
if let Some(permissions) = permissions {
let Some(snapshot) = snapshot.as_ref() else {
return Err(internal_error(format!(
"{method} permission selection missing thread snapshot"
)));
};
let overrides = ConfigOverrides {
cwd: cwd.clone(),
workspace_roots: Some(runtime_workspace_roots_request.clone().unwrap_or_else(
|| {
snapshot
.workspace_roots
.iter()
.map(AbsolutePathBuf::to_path_buf)
.collect()
},
)),
default_permissions: Some(permissions),
codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(),
main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(),
..Default::default()
};
let config = self
.config_manager
.load_for_cwd(
/*request_overrides*/ None,
overrides,
Some(snapshot.cwd.to_path_buf()),
)
.await
.map_err(|err| config_load_error(&err))?;
// Startup config is allowed to fall back when requirements
// disallow a configured profile. An explicit settings update
// is different: reject it before accepting the request.
if let Some(warning) = config.startup_warnings.iter().find(|warning| {
warning.contains("Configured value for `permission_profile` is disallowed")
}) {
return Err(invalid_request(format!(
"invalid thread settings override: {warning}"
)));
}
(
Some(config.permissions.permission_profile().clone()),
config.permissions.active_permission_profile(),
Some(config.permissions.profile_workspace_roots().to_vec()),
)
} else {
(None, None, None)
};
let effort = effort.map(Some);
if has_any_overrides {
thread
.preview_thread_settings_overrides(CodexThreadSettingsOverrides {
cwd: cwd.clone(),
workspace_roots: runtime_workspace_roots.clone(),
approval_policy,
approvals_reviewer,
sandbox_policy: sandbox_policy.clone(),
permission_profile: permission_profile.clone(),
active_permission_profile: active_permission_profile.clone(),
profile_workspace_roots: profile_workspace_roots.clone(),
windows_sandbox_level: None,
model: model.clone(),
effort,
summary,
service_tier: service_tier.clone(),
collaboration_mode: collaboration_mode.clone(),
personality,
})
.await
.map_err(|err| {
invalid_request(format!("invalid thread settings override: {err}"))
})?;
}
Ok(codex_protocol::protocol::ThreadSettingsOverrides {
cwd,
workspace_roots: runtime_workspace_roots,
profile_workspace_roots,
approval_policy,
approvals_reviewer,
sandbox_policy,
permission_profile,
active_permission_profile,
windows_sandbox_level: None,
model,
effort,
summary,
service_tier,
collaboration_mode,
personality,
})
}
async fn thread_settings_update_inner(
&self,
request_id: &ConnectionRequestId,
params: ThreadSettingsUpdateParams,
) -> Result<ThreadSettingsUpdateResponse, JSONRPCErrorError> {
let (_, thread) = self.load_thread(&params.thread_id).await?;
let thread_settings = self
.build_thread_settings_overrides(
thread.as_ref(),
ThreadSettingsBuildParams {
method: "thread/settings/update",
cwd: params.cwd,
runtime_workspace_roots: None,
approval_policy: params.approval_policy,
approvals_reviewer: params.approvals_reviewer,
sandbox_policy: params.sandbox_policy,
permissions: params.permissions,
model: params.model,
service_tier: params.service_tier,
effort: params.effort,
summary: params.summary,
collaboration_mode: params.collaboration_mode,
personality: params.personality,
},
)
.await?;
if thread_settings != codex_protocol::protocol::ThreadSettingsOverrides::default() {
self.submit_core_op(
request_id,
thread.as_ref(),
Op::ThreadSettings { thread_settings },
)
.await
.map_err(|err| internal_error(format!("failed to update thread settings: {err}")))?;
}
Ok(ThreadSettingsUpdateResponse {})
}
async fn thread_inject_items_response_inner(
&self,
params: ThreadInjectItemsParams,
+64
View File
@@ -3,6 +3,7 @@ use crate::outgoing_message::ConnectionRequestId;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadGoal;
use codex_app_server_protocol::ThreadHistoryBuilder;
use codex_app_server_protocol::ThreadSettings;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnError;
use codex_core::CodexThread;
@@ -76,6 +77,7 @@ pub(crate) struct ThreadState {
pub(crate) cancel_tx: Option<oneshot::Sender<()>>,
pub(crate) experimental_raw_events: bool,
pub(crate) listener_generation: u64,
last_thread_settings: Option<ThreadSettings>,
listener_command_tx: Option<mpsc::UnboundedSender<ThreadListenerCommand>>,
current_turn_history: ThreadHistoryBuilder,
listener_thread: Option<Weak<CodexThread>>,
@@ -95,11 +97,13 @@ impl ThreadState {
cancel_tx: oneshot::Sender<()>,
conversation: &Arc<CodexThread>,
watch_registration: WatchRegistration,
thread_settings_baseline: ThreadSettings,
) -> (mpsc::UnboundedReceiver<ThreadListenerCommand>, u64) {
if let Some(previous) = self.cancel_tx.replace(cancel_tx) {
let _ = previous.send(());
}
self.listener_generation = self.listener_generation.wrapping_add(1);
self.last_thread_settings = Some(thread_settings_baseline);
let (listener_command_tx, listener_command_rx) = mpsc::unbounded_channel();
self.listener_command_tx = Some(listener_command_tx);
self.listener_thread = Some(Arc::downgrade(conversation));
@@ -143,6 +147,12 @@ impl ThreadState {
self.current_turn_history.reset();
}
}
pub(crate) fn note_thread_settings(&mut self, thread_settings: ThreadSettings) -> bool {
let changed = self.last_thread_settings.as_ref() != Some(&thread_settings);
self.last_thread_settings = Some(thread_settings);
changed
}
}
pub(crate) async fn resolve_server_request_on_thread_listener(
@@ -177,6 +187,60 @@ pub(crate) async fn resolve_server_request_on_thread_listener(
}
}
#[cfg(test)]
mod tests {
use super::*;
use codex_app_server_protocol::ApprovalsReviewer;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::SandboxPolicy;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Settings;
use pretty_assertions::assert_eq;
#[test]
fn note_thread_settings_reports_only_effective_changes() {
let mut state = ThreadState::default();
let initial = thread_settings("mock-model");
let updated = thread_settings("mock-model-2");
let results = vec![
state.note_thread_settings(initial.clone()),
state.note_thread_settings(initial),
state.note_thread_settings(updated.clone()),
state.note_thread_settings(updated),
];
assert_eq!(results, vec![true, false, true, false]);
}
fn thread_settings(model: &str) -> ThreadSettings {
ThreadSettings {
cwd: AbsolutePathBuf::from_absolute_path("/tmp").expect("absolute path"),
approval_policy: AskForApproval::OnRequest,
approvals_reviewer: ApprovalsReviewer::User,
sandbox_policy: SandboxPolicy::ReadOnly {
network_access: false,
},
active_permission_profile: None,
model: model.to_string(),
model_provider: "mock_provider".to_string(),
service_tier: None,
effort: None,
summary: None,
collaboration_mode: CollaborationMode {
mode: ModeKind::Default,
settings: Settings {
model: model.to_string(),
reasoning_effort: None,
developer_instructions: None,
},
},
personality: None,
}
}
}
struct ThreadEntry {
state: Arc<Mutex<ThreadState>>,
connection_ids: HashSet<ConnectionId>,
@@ -89,6 +89,7 @@ use codex_app_server_protocol::ThreadRealtimeStopParams;
use codex_app_server_protocol::ThreadResumeParams;
use codex_app_server_protocol::ThreadRollbackParams;
use codex_app_server_protocol::ThreadSetNameParams;
use codex_app_server_protocol::ThreadSettingsUpdateParams;
use codex_app_server_protocol::ThreadShellCommandParams;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadTurnsItemsListParams;
@@ -444,6 +445,15 @@ impl McpProcess {
self.send_request("thread/metadata/update", params).await
}
/// Send a `thread/settings/update` JSON-RPC request.
pub async fn send_thread_settings_update_request(
&mut self,
params: ThreadSettingsUpdateParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("thread/settings/update", params).await
}
/// Send a `thread/unsubscribe` JSON-RPC request.
pub async fn send_thread_unsubscribe_request(
&mut self,
@@ -15,6 +15,7 @@ use codex_app_server_protocol::ThreadMemoryMode;
use codex_app_server_protocol::ThreadMemoryModeSetParams;
use codex_app_server_protocol::ThreadRealtimeStartParams;
use codex_app_server_protocol::ThreadRealtimeStartTransport;
use codex_app_server_protocol::ThreadSettingsUpdateParams;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_protocol::protocol::RealtimeOutputModality;
@@ -129,6 +130,40 @@ async fn thread_memory_mode_set_requires_experimental_api_capability() -> Result
Ok(())
}
#[tokio::test]
async fn thread_settings_update_requires_experimental_api_capability() -> Result<()> {
let codex_home = TempDir::new()?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
let init = mcp
.initialize_with_capabilities(
default_client_info(),
Some(InitializeCapabilities {
experimental_api: false,
request_attestation: false,
opt_out_notification_methods: None,
}),
)
.await?;
let JSONRPCMessage::Response(_) = init else {
anyhow::bail!("expected initialize response, got {init:?}");
};
let request_id = mcp
.send_thread_settings_update_request(ThreadSettingsUpdateParams {
thread_id: "thr_123".to_string(),
..Default::default()
})
.await?;
let error = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_experimental_capability_error(error, "thread/settings/update");
Ok(())
}
#[tokio::test]
async fn realtime_webrtc_start_requires_experimental_api_capability() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -58,6 +58,7 @@ mod thread_name_websocket;
mod thread_read;
mod thread_resume;
mod thread_rollback;
mod thread_settings_update;
mod thread_shell_command;
mod thread_start;
mod thread_status;
@@ -0,0 +1,400 @@
use anyhow::Context;
use anyhow::Result;
use app_test_support::McpProcess;
use app_test_support::create_final_assistant_message_sse_response;
use app_test_support::create_mock_responses_server_sequence_unchecked;
use app_test_support::to_response;
use app_test_support::write_mock_responses_config_toml;
use app_test_support::write_models_cache;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SandboxPolicy;
use codex_app_server_protocol::ThreadReadParams;
use codex_app_server_protocol::ThreadReadResponse;
use codex_app_server_protocol::ThreadSettingsUpdateParams;
use codex_app_server_protocol::ThreadSettingsUpdateResponse;
use codex_app_server_protocol::ThreadSettingsUpdatedNotification;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::UserInput as V2UserInput;
use codex_core::test_support::all_model_presets;
use core_test_support::responses;
use pretty_assertions::assert_eq;
use serde_json::Value;
use std::collections::BTreeMap;
use std::time::Duration;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
#[tokio::test]
async fn thread_settings_update_emits_notification_and_updates_future_turns() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(vec![
create_final_assistant_message_sse_response("done")?,
])
.await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
write_models_cache(codex_home.path())?;
let (model_id, service_tier_id) = service_tier_model_and_tier_id()?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let thread = start_thread(&mut mcp).await?.thread;
send_thread_settings_update(
&mut mcp,
ThreadSettingsUpdateParams {
thread_id: thread.id.clone(),
model: Some(model_id.clone()),
service_tier: Some(Some(service_tier_id.clone())),
..Default::default()
},
)
.await?;
assert!(
received_response_bodies(&server).await?.is_empty(),
"settings-only update should not start a model request"
);
start_text_turn(&mut mcp, thread.id.clone()).await?;
let updated = read_thread_settings_updated(&mut mcp).await?;
assert_eq!(updated.thread_id, thread.id);
assert_eq!(updated.thread_settings.model, model_id);
assert_eq!(
updated.thread_settings.service_tier.as_deref(),
Some(service_tier_id.as_str())
);
timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
let read = read_thread_with_turns(&mut mcp, &thread.id).await?;
assert_eq!(read.thread.turns.len(), 1);
let request_bodies = received_response_bodies(&server).await?;
assert!(
request_bodies.iter().any(|body| {
body.get("model").and_then(Value::as_str) == Some(model_id.as_str())
&& body.get("service_tier").and_then(Value::as_str)
== Some(service_tier_id.as_str())
}),
"future turn did not use updated model/service tier: {request_bodies:#?}"
);
Ok(())
}
#[tokio::test]
async fn thread_settings_update_while_turn_is_active_emits_notification() -> Result<()> {
let server = responses::start_mock_server().await;
let first_response =
responses::sse_response(create_final_assistant_message_sse_response("first done")?)
.set_delay(Duration::from_secs(2));
let _requests = responses::mount_response_sequence(&server, vec![first_response]).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let thread = start_thread(&mut mcp).await?.thread;
start_text_turn(&mut mcp, thread.id.clone()).await?;
timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_notification_message("turn/started"),
)
.await??;
send_thread_settings_update(
&mut mcp,
ThreadSettingsUpdateParams {
thread_id: thread.id.clone(),
model: Some("mock-model-4".to_string()),
..Default::default()
},
)
.await?;
let updated = read_thread_settings_updated(&mut mcp).await?;
assert_eq!(updated.thread_id, thread.id);
assert_eq!(updated.thread_settings.model, "mock-model-4");
timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
Ok(())
}
#[tokio::test]
async fn thread_settings_update_clears_service_tier() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(vec![
create_final_assistant_message_sse_response("done")?,
])
.await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
write_models_cache(codex_home.path())?;
let (model_id, service_tier_id) = service_tier_model_and_tier_id()?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let thread = start_thread(&mut mcp).await?.thread;
send_thread_settings_update(
&mut mcp,
ThreadSettingsUpdateParams {
thread_id: thread.id.clone(),
model: Some(model_id.clone()),
service_tier: Some(Some(service_tier_id.clone())),
..Default::default()
},
)
.await?;
let set_updated = read_thread_settings_updated(&mut mcp).await?;
assert_eq!(set_updated.thread_id, thread.id);
assert_eq!(
set_updated.thread_settings.service_tier.as_deref(),
Some(service_tier_id.as_str())
);
send_thread_settings_update(
&mut mcp,
ThreadSettingsUpdateParams {
thread_id: thread.id.clone(),
service_tier: Some(None),
..Default::default()
},
)
.await?;
let clear_updated = read_thread_settings_updated(&mut mcp).await?;
assert_eq!(clear_updated.thread_id, thread.id);
assert_eq!(clear_updated.thread_settings.model, model_id);
assert_eq!(clear_updated.thread_settings.service_tier, None);
start_text_turn(&mut mcp, thread.id).await?;
timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
let request_bodies = received_response_bodies(&server).await?;
assert!(
request_bodies.iter().any(|body| {
body.get("model").and_then(Value::as_str) == Some(model_id.as_str())
&& body
.as_object()
.is_some_and(|object| !object.contains_key("service_tier"))
}),
"future turn did not clear service tier: {request_bodies:#?}"
);
Ok(())
}
#[tokio::test]
async fn thread_settings_update_rejects_sandbox_policy_with_permissions() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let thread = start_thread(&mut mcp).await?.thread;
let request_id = mcp
.send_thread_settings_update_request(ThreadSettingsUpdateParams {
thread_id: thread.id,
sandbox_policy: Some(SandboxPolicy::DangerFullAccess),
permissions: Some(":workspace".to_string()),
..Default::default()
})
.await?;
let error: JSONRPCError = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(
error.error.message,
"`permissions` cannot be combined with `sandboxPolicy`"
);
Ok(())
}
#[tokio::test]
async fn turn_start_settings_override_emits_thread_settings_updated() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(vec![
create_final_assistant_message_sse_response("done")?,
])
.await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let thread = start_thread(&mut mcp).await?.thread;
timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_notification_message("thread/started"),
)
.await??;
let turn_request_id = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
input: vec![V2UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
}],
model: Some("mock-model-3".to_string()),
..Default::default()
})
.await?;
let turn_response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn_request_id)),
)
.await??;
let TurnStartResponse { turn } = to_response(turn_response)?;
assert!(!turn.id.is_empty());
let updated = read_thread_settings_updated(&mut mcp).await?;
assert_eq!(updated.thread_id, thread.id);
assert_eq!(updated.thread_settings.model, "mock-model-3");
timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
Ok(())
}
async fn send_thread_settings_update(
mcp: &mut McpProcess,
params: ThreadSettingsUpdateParams,
) -> Result<()> {
let request_id = mcp.send_thread_settings_update_request(params).await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let _: ThreadSettingsUpdateResponse = to_response(response)?;
Ok(())
}
async fn start_text_turn(mcp: &mut McpProcess, thread_id: String) -> Result<()> {
let turn_request_id = mcp
.send_turn_start_request(TurnStartParams {
thread_id,
input: vec![V2UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
let turn_response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn_request_id)),
)
.await??;
let TurnStartResponse { turn } = to_response(turn_response)?;
assert!(!turn.id.is_empty());
Ok(())
}
async fn start_thread(mcp: &mut McpProcess) -> Result<ThreadStartResponse> {
let request_id = mcp
.send_thread_start_request(ThreadStartParams {
model: Some("mock-model".to_string()),
..Default::default()
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
to_response(response)
}
async fn read_thread_with_turns(
mcp: &mut McpProcess,
thread_id: &str,
) -> Result<ThreadReadResponse> {
let request_id = mcp
.send_thread_read_request(ThreadReadParams {
thread_id: thread_id.to_string(),
include_turns: true,
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
to_response(response)
}
async fn read_thread_settings_updated(
mcp: &mut McpProcess,
) -> Result<ThreadSettingsUpdatedNotification> {
let notification: JSONRPCNotification = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_notification_message("thread/settings/updated"),
)
.await??;
let params = notification
.params
.context("thread/settings/updated should include params")?;
Ok(serde_json::from_value(params)?)
}
async fn received_response_bodies(server: &wiremock::MockServer) -> Result<Vec<Value>> {
let requests = server
.received_requests()
.await
.context("failed to fetch received requests")?;
let mut bodies = Vec::new();
for request in requests {
if request.url.path().ends_with("/responses") {
bodies.push(request.body_json::<Value>()?);
}
}
Ok(bodies)
}
fn service_tier_model_and_tier_id() -> Result<(String, String)> {
let model = all_model_presets()
.iter()
.find(|preset| preset.show_in_picker && !preset.service_tiers.is_empty())
.context("bundled model catalog should include a picker model with service tiers")?;
Ok((model.id.clone(), model.service_tiers[0].id.clone()))
}
fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> {
write_mock_responses_config_toml(
codex_home,
server_uri,
&BTreeMap::default(),
/*auto_compact_limit*/ 200_000,
/*requires_openai_auth*/ None,
"mock_provider",
"compact",
)
}
@@ -88,8 +88,10 @@ impl CoreToolRuntime for ExtensionToolAdapter {
fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall {
ExtensionToolCall {
turn_id: invocation.turn.sub_id.clone(),
call_id: invocation.call_id.clone(),
tool_name: invocation.tool_name.clone(),
truncation_policy: invocation.turn.truncation_policy,
payload: invocation.payload.clone(),
}
}
@@ -108,6 +110,7 @@ mod tests {
use pretty_assertions::assert_eq;
use serde_json::json;
use tokio::sync::Mutex;
use super::ExtensionToolAdapter;
use crate::tools::context::ToolCallSource;
@@ -158,6 +161,27 @@ mod tests {
}
}
struct CapturingExtensionExecutor {
captured_call: Arc<Mutex<Option<codex_tools::ToolCall>>>,
}
#[async_trait::async_trait]
impl codex_extension_api::ToolExecutor<codex_tools::ToolCall> for CapturingExtensionExecutor {
fn tool_name(&self) -> codex_tools::ToolName {
codex_tools::ToolName::plain("extension_echo")
}
async fn handle(
&self,
call: codex_tools::ToolCall,
) -> Result<Box<dyn codex_tools::ToolOutput>, codex_tools::FunctionCallError> {
*self.captured_call.lock().await = Some(call);
Ok(Box::new(codex_tools::JsonToolOutput::new(
json!({ "ok": true }),
)))
}
}
#[tokio::test]
async fn exposes_generic_hook_payloads() {
let handler = ExtensionToolAdapter::new(Arc::new(StubExtensionExecutor));
@@ -193,4 +217,46 @@ mod tests {
})
);
}
#[tokio::test]
async fn passes_turn_fields_to_extension_call() {
let captured_call = Arc::new(Mutex::new(None));
let handler = ExtensionToolAdapter::new(Arc::new(CapturingExtensionExecutor {
captured_call: Arc::clone(&captured_call),
}));
let (session, turn) = crate::session::tests::make_session_and_context().await;
let turn_id = turn.sub_id.clone();
let truncation_policy = turn.truncation_policy;
let invocation = ToolInvocation {
session: session.into(),
turn: turn.into(),
cancellation_token: tokio_util::sync::CancellationToken::new(),
tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())),
call_id: "call-extension".to_string(),
tool_name: codex_tools::ToolName::plain("extension_echo"),
source: ToolCallSource::Direct,
payload: ToolPayload::Function {
arguments: json!({ "message": "hello" }).to_string(),
},
};
crate::tools::registry::ToolExecutor::handle(&handler, invocation)
.await
.expect("extension call should succeed");
let captured_call = captured_call.lock().await.clone().expect("captured call");
assert_eq!(captured_call.turn_id, turn_id);
assert_eq!(captured_call.call_id, "call-extension");
assert_eq!(
captured_call.tool_name,
codex_tools::ToolName::plain("extension_echo")
);
assert_eq!(captured_call.truncation_policy, truncation_policy);
match captured_call.payload {
ToolPayload::Function { arguments } => {
assert_eq!(arguments, json!({ "message": "hello" }).to_string());
}
payload => panic!("expected function payload, got {payload:?}"),
}
}
}
+9
View File
@@ -13,6 +13,7 @@ use codex_tools::ToolOutput;
use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_absolute_path::test_support::PathExt;
use codex_utils_absolute_path::test_support::test_path_buf;
use codex_utils_output_truncation::TruncationPolicy;
use pretty_assertions::assert_eq;
use serde_json::json;
@@ -134,8 +135,10 @@ async fn read_tool_reads_memory_file() {
let output = tool
.handle(ToolCall {
turn_id: "turn-1".to_string(),
call_id: "call-1".to_string(),
tool_name: memory_tool_name(crate::READ_TOOL_NAME),
truncation_policy: TruncationPolicy::Bytes(1024),
payload: payload.clone(),
})
.await
@@ -176,8 +179,10 @@ async fn search_tool_accepts_multiple_queries() {
let output = tool
.handle(ToolCall {
turn_id: "turn-1".to_string(),
call_id: "call-1".to_string(),
tool_name: memory_tool_name(crate::SEARCH_TOOL_NAME),
truncation_policy: TruncationPolicy::Bytes(1024),
payload: payload.clone(),
})
.await
@@ -244,8 +249,10 @@ async fn search_tool_accepts_windowed_all_match_mode() {
let output = tool
.handle(ToolCall {
turn_id: "turn-1".to_string(),
call_id: "call-1".to_string(),
tool_name: memory_tool_name(crate::SEARCH_TOOL_NAME),
truncation_policy: TruncationPolicy::Bytes(1024),
payload: payload.clone(),
})
.await
@@ -292,8 +299,10 @@ async fn search_tool_rejects_legacy_single_query() {
let result = tool
.handle(ToolCall {
turn_id: "turn-1".to_string(),
call_id: "call-1".to_string(),
tool_name: memory_tool_name(crate::SEARCH_TOOL_NAME),
truncation_policy: TruncationPolicy::Bytes(1024),
payload,
})
.await;
+1
View File
@@ -14,6 +14,7 @@ codex-code-mode = { workspace = true }
codex-features = { workspace = true }
codex-protocol = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-output-truncation = { workspace = true }
codex-utils-pty = { workspace = true }
codex-utils-string = { workspace = true }
rmcp = { workspace = true, default-features = false, features = [
+3
View File
@@ -1,12 +1,15 @@
use crate::FunctionCallError;
use crate::ToolName;
use crate::ToolPayload;
use codex_utils_output_truncation::TruncationPolicy;
// TODO: this is temporary and will disappear in the next PR (as we make codex-extension-api generic on Invocation.
#[derive(Clone, Debug)]
pub struct ToolCall {
pub turn_id: String,
pub call_id: String,
pub tool_name: ToolName,
pub truncation_policy: TruncationPolicy,
pub payload: ToolPayload,
}
+1
View File
@@ -211,6 +211,7 @@ mod thread_events;
mod thread_goal_actions;
mod thread_routing;
mod thread_session_state;
mod thread_settings;
use self::agent_navigation::AgentNavigationDirection;
use self::agent_navigation::AgentNavigationState;
@@ -62,6 +62,9 @@ pub(super) fn server_notification_thread_target(
ServerNotification::ThreadGoalCleared(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::ThreadSettingsUpdated(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::TurnStarted(notification) => Some(notification.thread_id.as_str()),
ServerNotification::HookStarted(notification) => Some(notification.thread_id.as_str()),
ServerNotification::TurnCompleted(notification) => Some(notification.thread_id.as_str()),
@@ -175,12 +178,46 @@ pub(super) fn server_notification_thread_target(
mod tests {
use super::ServerNotificationThreadTarget;
use super::server_notification_thread_target;
use crate::test_support::PathBufExt;
use crate::test_support::test_path_buf;
use codex_app_server_protocol::GuardianWarningNotification;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ThreadSettings;
use codex_app_server_protocol::ThreadSettingsUpdatedNotification;
use codex_app_server_protocol::WarningNotification;
use codex_protocol::ThreadId;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Settings;
use codex_protocol::openai_models::ReasoningEffort;
use pretty_assertions::assert_eq;
fn test_thread_settings() -> ThreadSettings {
ThreadSettings {
cwd: test_path_buf("/tmp/thread-settings").abs(),
approval_policy: codex_app_server_protocol::AskForApproval::Never,
approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::User,
sandbox_policy: codex_app_server_protocol::SandboxPolicy::ReadOnly {
network_access: false,
},
active_permission_profile: None,
model: "gpt-5.4".to_string(),
model_provider: "openai".to_string(),
service_tier: None,
effort: Some(ReasoningEffort::High),
summary: None,
collaboration_mode: CollaborationMode {
mode: ModeKind::Default,
settings: Settings {
model: "gpt-5.4".to_string(),
reasoning_effort: Some(ReasoningEffort::High),
developer_instructions: None,
},
},
personality: None,
}
}
#[test]
fn warning_notifications_without_threads_are_global() {
let notification = ServerNotification::Warning(WarningNotification {
@@ -218,4 +255,18 @@ mod tests {
assert_eq!(target, ServerNotificationThreadTarget::Thread(thread_id));
}
#[test]
fn thread_settings_updated_notifications_route_to_threads() {
let thread_id = ThreadId::new();
let notification =
ServerNotification::ThreadSettingsUpdated(ThreadSettingsUpdatedNotification {
thread_id: thread_id.to_string(),
thread_settings: test_thread_settings(),
});
let target = server_notification_thread_target(&notification);
assert_eq!(target, ServerNotificationThreadTarget::Thread(thread_id));
}
}
@@ -692,6 +692,8 @@ mod tests {
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
collaboration_mode: None,
personality: None,
message_history: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
+8
View File
@@ -756,12 +756,18 @@ impl App {
}
AppEvent::UpdateReasoningEffort(effort) => {
self.on_update_reasoning_effort(effort);
self.sync_active_thread_reasoning_setting(app_server, effort)
.await;
}
AppEvent::UpdateModel(model) => {
self.chat_widget.set_model(&model);
self.sync_active_thread_model_setting(app_server, model)
.await;
}
AppEvent::UpdatePersonality(personality) => {
self.on_update_personality(personality);
self.sync_active_thread_personality_setting(app_server, personality)
.await;
}
AppEvent::OpenRealtimeAudioDeviceSelection { kind } => {
self.chat_widget.open_realtime_audio_device_selection(kind);
@@ -1544,6 +1550,8 @@ impl App {
AppEvent::UpdatePlanModeReasoningEffort(effort) => {
self.config.plan_mode_reasoning_effort = effort;
self.chat_widget.set_plan_mode_reasoning_effort(effort);
self.sync_active_thread_plan_mode_reasoning_setting(app_server)
.await;
}
AppEvent::PersistFullAccessWarningAcknowledged => {
if let Err(err) = ConfigEditsBuilder::for_config(&self.config)
+329
View File
@@ -59,6 +59,8 @@ use codex_app_server_protocol::SessionSource;
use codex_app_server_protocol::Thread;
use codex_app_server_protocol::ThreadClosedNotification;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadSettings;
use codex_app_server_protocol::ThreadSettingsUpdatedNotification;
use codex_app_server_protocol::ThreadStartedNotification;
use codex_app_server_protocol::ThreadTokenUsage;
use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification;
@@ -77,8 +79,10 @@ use codex_protocol::ThreadId;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::CollaborationModeMask;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::Settings;
use codex_protocol::models::ActivePermissionProfile;
use codex_protocol::models::FileSystemPermissions;
use codex_protocol::models::NetworkPermissions;
use codex_protocol::models::PermissionProfile;
@@ -108,6 +112,29 @@ fn test_absolute_path(path: &str) -> AbsolutePathBuf {
AbsolutePathBuf::try_from(PathBuf::from(path)).expect("absolute test path")
}
async fn next_thread_settings_updated(
app_server: &mut AppServerSession,
thread_id: ThreadId,
) -> ThreadSettingsUpdatedNotification {
for _ in 0..20 {
let event = time::timeout(
std::time::Duration::from_secs(/*secs*/ 2),
app_server.next_event(),
)
.await
.expect("app-server should emit an event")
.expect("app-server event stream should remain open");
if let codex_app_server_client::AppServerEvent::ServerNotification(
ServerNotification::ThreadSettingsUpdated(notification),
) = event
&& notification.thread_id == thread_id.to_string()
{
return notification;
}
}
panic!("expected ThreadSettingsUpdated for thread {thread_id}");
}
#[tokio::test]
async fn handle_mcp_inventory_result_clears_committed_loading_cell() {
let mut app = make_test_app().await;
@@ -3721,6 +3748,8 @@ async fn render_clear_ui_header_after_long_transcript_for_snapshot() -> String {
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::High),
collaboration_mode: None,
personality: None,
message_history: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
@@ -3970,6 +3999,8 @@ fn test_thread_session(thread_id: ThreadId, cwd: PathBuf) -> ThreadSessionState
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
collaboration_mode: None,
personality: None,
message_history: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
@@ -4546,6 +4577,8 @@ async fn backtrack_selection_with_duplicate_history_targets_unique_turn() {
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
collaboration_mode: None,
personality: None,
message_history: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
@@ -4610,6 +4643,8 @@ async fn backtrack_selection_with_duplicate_history_targets_unique_turn() {
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
collaboration_mode: None,
personality: None,
message_history: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
@@ -4703,6 +4738,8 @@ async fn backtrack_resubmit_preserves_data_image_urls_in_user_turn() {
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
collaboration_mode: None,
personality: None,
message_history: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
@@ -5103,6 +5140,8 @@ async fn new_session_requests_shutdown_for_previous_conversation() {
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
collaboration_mode: None,
personality: None,
message_history: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
@@ -5204,6 +5243,294 @@ async fn interrupt_without_active_turn_is_treated_as_handled() {
.await;
}
#[tokio::test]
async fn override_turn_context_sends_thread_settings_update() {
Box::pin(async {
let mut app = make_test_app().await;
let mut app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
.await
.expect("embedded app server");
let started = app_server
.start_thread(app.chat_widget.config_ref())
.await
.expect("thread/start should succeed");
let thread_id = started.session.thread_id;
let initial_model = started.session.model.clone();
let initial_effort = started.session.reasoning_effort;
app.enqueue_primary_thread_session(started.session, started.turns)
.await
.expect("primary thread should be registered");
let service_tier = ServiceTier::Fast.request_value().to_string();
let collaboration_mode = CollaborationMode {
mode: ModeKind::Plan,
settings: Settings {
model: "gpt-5.4".to_string(),
reasoning_effort: Some(ReasoningEffortConfig::High),
developer_instructions: None,
},
};
let op = AppCommand::override_turn_context(
/*cwd*/ None,
Some(AskForApproval::OnRequest),
Some(ApprovalsReviewer::AutoReview),
Some(ActivePermissionProfile::new(
codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE,
)),
/*windows_sandbox_level*/ None,
Some("gpt-5.4".to_string()),
Some(Some(ReasoningEffortConfig::High)),
/*summary*/ None,
Some(Some(service_tier.clone())),
Some(collaboration_mode.clone()),
Some(Personality::Pragmatic),
);
let handled = app
.try_submit_active_thread_op_via_app_server(&mut app_server, thread_id, &op)
.await
.expect("settings update submission should not fail");
assert_eq!(handled, true);
assert_eq!(
app.primary_session_configured
.as_ref()
.expect("primary session")
.model,
initial_model,
"thread/settings/update response is only an ack; cached state changes on notification"
);
let notification = next_thread_settings_updated(&mut app_server, thread_id).await;
assert_eq!(notification.thread_settings.model, "gpt-5.4");
assert_eq!(
notification.thread_settings.effort,
Some(ReasoningEffortConfig::High)
);
assert_eq!(
notification.thread_settings.service_tier,
Some(service_tier.clone())
);
assert_eq!(
notification.thread_settings.approval_policy,
AskForApproval::OnRequest
);
assert_eq!(
notification.thread_settings.approvals_reviewer.to_core(),
ApprovalsReviewer::AutoReview
);
let notified_mode = &notification.thread_settings.collaboration_mode;
assert_eq!(notified_mode.mode, collaboration_mode.mode);
assert_eq!(
notified_mode.settings.model,
collaboration_mode.settings.model
);
assert_eq!(
notified_mode.settings.reasoning_effort,
collaboration_mode.settings.reasoning_effort
);
assert_eq!(
notification.thread_settings.personality,
Some(Personality::Pragmatic)
);
app.handle_app_server_event(
&app_server,
codex_app_server_client::AppServerEvent::ServerNotification(
ServerNotification::ThreadSettingsUpdated(notification),
),
)
.await;
let updated_session = app
.primary_session_configured
.as_ref()
.expect("primary session should be updated from notification");
assert_eq!(updated_session.model, initial_model);
assert_eq!(updated_session.reasoning_effort, initial_effort);
let updated_mode = updated_session
.collaboration_mode
.as_deref()
.expect("collaboration mode should be cached");
assert_eq!(updated_mode.mode, collaboration_mode.mode);
assert_eq!(
updated_mode.settings.model,
collaboration_mode.settings.model
);
assert_eq!(
updated_mode.settings.reasoning_effort,
collaboration_mode.settings.reasoning_effort
);
assert_eq!(updated_session.personality, Some(Personality::Pragmatic));
assert_eq!(updated_session.service_tier, Some(service_tier));
assert_eq!(updated_session.approval_policy, AskForApproval::OnRequest);
assert_eq!(
updated_session.approvals_reviewer,
ApprovalsReviewer::AutoReview
);
assert_eq!(
updated_session
.active_permission_profile
.as_ref()
.expect("active profile")
.id,
codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE
);
})
.await;
}
#[tokio::test]
async fn thread_setting_update_params_sync_model_and_default_reasoning() {
let mut app = make_test_app().await;
let thread_id = ThreadId::new();
app.active_thread_id = Some(thread_id);
app.chat_widget.set_model("gpt-5.4");
let params = app
.active_thread_model_setting_update_params("gpt-5.4".to_string())
.expect("active thread should produce update params");
assert_eq!(params.thread_id, thread_id.to_string());
assert_eq!(params.model, Some("gpt-5.4".to_string()));
assert_eq!(
params
.collaboration_mode
.as_ref()
.expect("collaboration mode should sync with model")
.settings
.model,
"gpt-5.4"
);
app.chat_widget
.set_reasoning_effort(Some(ReasoningEffortConfig::Low));
app.chat_widget
.set_collaboration_mask(CollaborationModeMask {
name: "Plan".to_string(),
mode: Some(ModeKind::Plan),
model: Some("gpt-plan".to_string()),
reasoning_effort: Some(Some(ReasoningEffortConfig::Medium)),
developer_instructions: None,
});
app.on_update_reasoning_effort(Some(ReasoningEffortConfig::High));
let params = app
.active_thread_reasoning_setting_update_params(Some(ReasoningEffortConfig::High))
.expect("active thread should produce update params");
assert_eq!(params.thread_id, thread_id.to_string());
assert_eq!(params.effort, Some(ReasoningEffortConfig::High));
let collaboration_mode = params
.collaboration_mode
.expect("collaboration mode should sync with reasoning");
assert_eq!(collaboration_mode.mode, ModeKind::Default);
assert_eq!(
collaboration_mode.settings.reasoning_effort,
Some(ReasoningEffortConfig::High)
);
}
#[tokio::test]
async fn inactive_thread_settings_notification_updates_cached_collaboration_mode() {
let mut app = make_test_app().await;
let primary_thread_id = ThreadId::new();
let inactive_thread_id = ThreadId::new();
let primary_session = test_thread_session(primary_thread_id, test_path_buf("/tmp/main"));
let inactive_session = test_thread_session(inactive_thread_id, test_path_buf("/tmp/inactive"));
let collaboration_mode = CollaborationMode {
mode: ModeKind::Plan,
settings: Settings {
model: "gpt-plan".to_string(),
reasoning_effort: Some(ReasoningEffortConfig::High),
developer_instructions: Some("draft a plan first".to_string()),
},
};
app.primary_thread_id = Some(primary_thread_id);
app.active_thread_id = Some(primary_thread_id);
app.primary_session_configured = Some(primary_session.clone());
app.thread_event_channels.insert(
primary_thread_id,
ThreadEventChannel::new_with_session(
THREAD_EVENT_CHANNEL_CAPACITY,
primary_session,
Vec::new(),
),
);
app.thread_event_channels.insert(
inactive_thread_id,
ThreadEventChannel::new_with_session(
THREAD_EVENT_CHANNEL_CAPACITY,
inactive_session,
Vec::new(),
),
);
let notification = ThreadSettingsUpdatedNotification {
thread_id: inactive_thread_id.to_string(),
thread_settings: ThreadSettings {
cwd: test_absolute_path("/tmp/thread-settings"),
approval_policy: AskForApproval::OnRequest,
approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::AutoReview,
sandbox_policy: codex_app_server_protocol::SandboxPolicy::ReadOnly {
network_access: false,
},
active_permission_profile: Some(
codex_app_server_protocol::ActivePermissionProfile::read_only(),
),
model: "gpt-plan".to_string(),
model_provider: "openai".to_string(),
service_tier: None,
effort: collaboration_mode.settings.reasoning_effort,
summary: None,
collaboration_mode: collaboration_mode.clone(),
personality: Some(Personality::Pragmatic),
},
};
app.enqueue_thread_notification(
inactive_thread_id,
ServerNotification::ThreadSettingsUpdated(notification),
)
.await
.expect("settings notification should be cached");
let cached_session = app
.thread_event_channels
.get(&inactive_thread_id)
.expect("inactive thread channel")
.store
.lock()
.await
.session
.clone()
.expect("inactive session should remain cached");
assert_eq!(cached_session.model, "gpt-test");
assert_eq!(cached_session.personality, Some(Personality::Pragmatic));
assert_eq!(
cached_session.collaboration_mode.as_deref(),
Some(&collaboration_mode)
);
app.chat_widget.handle_thread_session(cached_session);
assert_eq!(
app.chat_widget.active_collaboration_mode_kind(),
ModeKind::Plan
);
assert_eq!(app.chat_widget.current_model(), "gpt-plan");
assert_eq!(
app.chat_widget.current_collaboration_mode().model(),
"gpt-test"
);
assert_eq!(
app.chat_widget.current_reasoning_effort(),
Some(ReasoningEffortConfig::High)
);
assert_eq!(
app.chat_widget.config_ref().personality,
Some(Personality::Pragmatic)
);
}
#[tokio::test]
async fn clear_only_ui_reset_preserves_chat_session_state() {
let mut app = make_test_app().await;
@@ -5225,6 +5552,8 @@ async fn clear_only_ui_reset_preserves_chat_session_state() {
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
collaboration_mode: None,
personality: None,
message_history: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
+2
View File
@@ -355,6 +355,8 @@ mod tests {
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
collaboration_mode: None,
personality: None,
message_history: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
+16 -1
View File
@@ -685,7 +685,11 @@ impl App {
self.refresh_in_memory_config_from_disk().await?;
Ok(true)
}
AppCommand::OverrideTurnContext { .. } => Ok(true),
AppCommand::OverrideTurnContext { .. } => {
self.sync_override_turn_context_settings(app_server, thread_id, op)
.await;
Ok(true)
}
AppCommand::ApproveGuardianDeniedAction { event } => {
app_server
.thread_approve_guardian_denied_action(thread_id, event)
@@ -825,6 +829,17 @@ impl App {
thread_id: ThreadId,
notification: ServerNotification,
) -> Result<()> {
if matches!(notification, ServerNotification::ThreadSettingsUpdated(_))
&& self.primary_thread_id.is_some()
&& self.primary_thread_id != Some(thread_id)
&& !self.thread_event_channels.contains_key(&thread_id)
{
return Ok(());
}
if let ServerNotification::ThreadSettingsUpdated(notification) = &notification {
self.apply_thread_settings_to_cached_session(thread_id, &notification.thread_settings)
.await;
}
let inferred_session = self
.infer_session_for_thread_notification(thread_id, &notification)
.await;
+16 -5
View File
@@ -78,10 +78,16 @@ impl App {
) -> ThreadSessionState {
let permission_profile = self.current_permission_profile();
let active_permission_profile = self.current_active_permission_profile();
let mut session = self
.primary_session_configured
.clone()
.unwrap_or(ThreadSessionState {
let mut session = if let Some(mut session) = self.primary_session_configured.clone() {
if session.thread_id != thread_id {
// `thread/read` does not include thread settings, so do not carry
// thread-scoped state from the currently active session.
session.collaboration_mode = None;
session.personality = None;
}
session
} else {
ThreadSessionState {
thread_id,
forked_from_id: None,
fork_parent_title: None,
@@ -99,10 +105,13 @@ impl App {
runtime_workspace_roots: self.config.workspace_roots.clone(),
instruction_source_paths: Vec::new(),
reasoning_effort: self.chat_widget.current_reasoning_effort(),
collaboration_mode: None,
personality: None,
message_history: None,
network_proxy: None,
rollout_path: thread.path.clone(),
});
}
};
session.thread_id = thread_id;
session.thread_name = thread.name.clone();
session.model_provider_id = thread.model_provider.clone();
@@ -178,6 +187,8 @@ mod tests {
runtime_workspace_roots: vec![cwd.abs()],
instruction_source_paths: Vec::new(),
reasoning_effort: None,
collaboration_mode: None,
personality: None,
message_history: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
+208
View File
@@ -0,0 +1,208 @@
//! Thread settings sync between TUI-local state and app-server thread state.
use super::App;
use crate::app_command::AppCommand;
use crate::app_server_session::AppServerSession;
use crate::session_state::ThreadSessionState;
use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer;
use codex_app_server_protocol::ThreadSettings;
use codex_app_server_protocol::ThreadSettingsUpdateParams;
use codex_protocol::ThreadId;
use codex_protocol::config_types::ModeKind;
use codex_protocol::models::PermissionProfile;
impl App {
pub(super) async fn sync_active_thread_model_setting(
&mut self,
app_server: &mut AppServerSession,
model: String,
) {
let Some(params) = self.active_thread_model_setting_update_params(model) else {
return;
};
self.send_thread_settings_update(app_server, params).await;
}
pub(super) fn active_thread_model_setting_update_params(
&self,
model: String,
) -> Option<ThreadSettingsUpdateParams> {
let thread_id = self.active_thread_id?;
Some(ThreadSettingsUpdateParams {
thread_id: thread_id.to_string(),
model: Some(model),
collaboration_mode: Some(self.chat_widget.effective_collaboration_mode()),
..ThreadSettingsUpdateParams::default()
})
}
pub(super) async fn sync_active_thread_reasoning_setting(
&mut self,
app_server: &mut AppServerSession,
effort: Option<codex_protocol::openai_models::ReasoningEffort>,
) {
let Some(params) = self.active_thread_reasoning_setting_update_params(effort) else {
return;
};
self.send_thread_settings_update(app_server, params).await;
}
pub(super) fn active_thread_reasoning_setting_update_params(
&self,
effort: Option<codex_protocol::openai_models::ReasoningEffort>,
) -> Option<ThreadSettingsUpdateParams> {
let thread_id = self.active_thread_id?;
Some(ThreadSettingsUpdateParams {
thread_id: thread_id.to_string(),
effort,
collaboration_mode: Some(self.chat_widget.current_collaboration_mode().clone()),
..ThreadSettingsUpdateParams::default()
})
}
pub(super) async fn sync_active_thread_plan_mode_reasoning_setting(
&mut self,
app_server: &mut AppServerSession,
) {
let Some(thread_id) = self.active_thread_id else {
return;
};
let params = ThreadSettingsUpdateParams {
thread_id: thread_id.to_string(),
collaboration_mode: Some(self.chat_widget.effective_collaboration_mode()),
..ThreadSettingsUpdateParams::default()
};
self.send_thread_settings_update(app_server, params).await;
}
pub(super) async fn sync_active_thread_personality_setting(
&mut self,
app_server: &mut AppServerSession,
personality: codex_protocol::config_types::Personality,
) {
let Some(thread_id) = self.active_thread_id else {
return;
};
let params = ThreadSettingsUpdateParams {
thread_id: thread_id.to_string(),
personality: Some(personality),
..ThreadSettingsUpdateParams::default()
};
self.send_thread_settings_update(app_server, params).await;
}
pub(super) async fn sync_override_turn_context_settings(
&mut self,
app_server: &mut AppServerSession,
thread_id: ThreadId,
op: &AppCommand,
) {
let AppCommand::OverrideTurnContext {
cwd,
approval_policy,
approvals_reviewer,
active_permission_profile,
windows_sandbox_level: _,
model,
effort,
summary,
service_tier,
collaboration_mode,
personality,
} = op
else {
return;
};
let params = ThreadSettingsUpdateParams {
thread_id: thread_id.to_string(),
cwd: cwd.clone(),
approval_policy: *approval_policy,
approvals_reviewer: approvals_reviewer.map(AppServerApprovalsReviewer::from),
permissions: active_permission_profile
.as_ref()
.map(|profile| profile.id.clone()),
model: model.clone(),
effort: effort.unwrap_or_default(),
summary: *summary,
service_tier: service_tier.clone(),
collaboration_mode: collaboration_mode.clone(),
personality: *personality,
..ThreadSettingsUpdateParams::default()
};
self.send_thread_settings_update(app_server, params).await;
}
pub(super) async fn apply_thread_settings_to_cached_session(
&mut self,
thread_id: ThreadId,
settings: &ThreadSettings,
) {
if self.primary_thread_id == Some(thread_id)
&& let Some(session) = self.primary_session_configured.as_mut()
{
apply_thread_settings_to_session(session, settings);
}
if let Some(channel) = self.thread_event_channels.get(&thread_id) {
let mut store = channel.store.lock().await;
if let Some(session) = store.session.as_mut() {
apply_thread_settings_to_session(session, settings);
}
}
}
async fn send_thread_settings_update(
&mut self,
app_server: &mut AppServerSession,
params: ThreadSettingsUpdateParams,
) {
if !thread_settings_update_has_changes(&params) {
return;
}
if let Err(err) = app_server.thread_settings_update(params).await {
tracing::warn!("failed to update app-server thread settings from TUI: {err}");
self.chat_widget
.add_error_message(format!("Failed to update thread settings: {err}"));
}
}
}
fn apply_thread_settings_to_session(session: &mut ThreadSessionState, settings: &ThreadSettings) {
if settings.collaboration_mode.mode == ModeKind::Default {
session.model = settings.model.clone();
session.reasoning_effort = settings.effort;
}
session.model_provider_id = settings.model_provider.clone();
session.service_tier = settings.service_tier.clone();
session.approval_policy = settings.approval_policy;
session.approvals_reviewer = settings.approvals_reviewer.to_core();
session.permission_profile = PermissionProfile::from_legacy_sandbox_policy_for_cwd(
&settings.sandbox_policy.to_core(),
settings.cwd.as_path(),
);
session.active_permission_profile = settings.active_permission_profile.clone().map(Into::into);
session.set_cwd_retargeting_implicit_runtime_workspace_root(settings.cwd.clone());
session.personality = settings.personality;
let mut collaboration_mode = settings.collaboration_mode.clone();
collaboration_mode
.settings
.model
.clone_from(&settings.model);
collaboration_mode.settings.reasoning_effort = settings.effort;
session.collaboration_mode = Some(Box::new(collaboration_mode));
}
fn thread_settings_update_has_changes(params: &ThreadSettingsUpdateParams) -> bool {
params.cwd.is_some()
|| params.approval_policy.is_some()
|| params.approvals_reviewer.is_some()
|| params.sandbox_policy.is_some()
|| params.permissions.is_some()
|| params.model.is_some()
|| params.service_tier.is_some()
|| params.effort.is_some()
|| params.summary.is_some()
|| params.collaboration_mode.is_some()
|| params.personality.is_some()
}
+80
View File
@@ -86,6 +86,8 @@ use codex_app_server_protocol::ThreadRollbackParams;
use codex_app_server_protocol::ThreadRollbackResponse;
use codex_app_server_protocol::ThreadSetNameParams;
use codex_app_server_protocol::ThreadSetNameResponse;
use codex_app_server_protocol::ThreadSettingsUpdateParams;
use codex_app_server_protocol::ThreadSettingsUpdateResponse;
use codex_app_server_protocol::ThreadShellCommandParams;
use codex_app_server_protocol::ThreadShellCommandResponse;
use codex_app_server_protocol::ThreadSource;
@@ -120,10 +122,20 @@ use color_eyre::eyre::WrapErr;
use std::collections::HashMap;
use std::path::PathBuf;
const JSONRPC_INVALID_REQUEST: i64 = -32600;
const JSONRPC_METHOD_NOT_FOUND: i64 = -32601;
const THREAD_SETTINGS_UPDATE_METHOD: &str = "thread/settings/update";
fn bootstrap_request_error(context: &'static str, err: TypedRequestError) -> color_eyre::Report {
color_eyre::eyre::eyre!("{context}: {err}")
}
fn is_thread_settings_update_unsupported(source: &JSONRPCErrorError) -> bool {
source.code == JSONRPC_METHOD_NOT_FOUND
|| (source.code == JSONRPC_INVALID_REQUEST
&& source.message.contains(THREAD_SETTINGS_UPDATE_METHOD))
}
/// Data collected during the TUI bootstrap phase that the main event loop
/// needs to configure the UI, telemetry, and initial rate-limit prefetch.
///
@@ -150,6 +162,7 @@ pub(crate) struct AppServerSession {
next_request_id: i64,
remote_cwd_override: Option<PathBuf>,
thread_params_mode: ThreadParamsMode,
thread_settings_update_supported: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -189,6 +202,7 @@ impl AppServerSession {
next_request_id: 1,
remote_cwd_override: None,
thread_params_mode,
thread_settings_update_supported: true,
}
}
@@ -525,6 +539,39 @@ impl AppServerSession {
.wrap_err("thread/metadata/update failed while syncing git branch")
}
pub(crate) async fn thread_settings_update(
&mut self,
params: ThreadSettingsUpdateParams,
) -> Result<()> {
if !self.thread_settings_update_supported {
return Ok(());
}
let request_id = self.next_request_id();
match self
.client
.request_typed::<ThreadSettingsUpdateResponse>(ClientRequest::ThreadSettingsUpdate {
request_id,
params,
})
.await
{
Ok(_) => Ok(()),
Err(TypedRequestError::Server { source, .. })
if is_thread_settings_update_unsupported(&source) =>
{
// Older remote app servers can reject this experimental method as
// method-not-found, experimental-capability-gated, or an unknown
// request variant. Treat those as a session-level capability
// downgrade so local TUI setting changes stay best-effort instead
// of showing an error every time the user changes model, effort,
// personality, or mode.
self.thread_settings_update_supported = false;
Ok(())
}
Err(err) => Err(err).wrap_err("thread/settings/update failed in TUI"),
}
}
pub(crate) async fn thread_inject_items(
&mut self,
thread_id: ThreadId,
@@ -1575,6 +1622,8 @@ async fn thread_session_state_from_thread_response(
runtime_workspace_roots,
instruction_source_paths,
reasoning_effort,
collaboration_mode: None,
personality: config.personality,
message_history: Some(MessageHistoryMetadata {
log_id,
entry_count,
@@ -1676,6 +1725,37 @@ mod tests {
);
}
#[test]
fn thread_settings_update_compat_detects_unsupported_errors() {
let cases = [
(JSONRPC_METHOD_NOT_FOUND, "method not found", true),
(
JSONRPC_INVALID_REQUEST,
"thread/settings/update requires experimentalApi capability",
true,
),
(
JSONRPC_INVALID_REQUEST,
"Invalid request: unknown variant `thread/settings/update`",
true,
),
(JSONRPC_INVALID_REQUEST, "invalid thread id", false),
];
for (code, message, expected) in cases {
let source = JSONRPCErrorError {
code,
data: None,
message: message.to_string(),
};
assert_eq!(
is_thread_settings_update_unsupported(&source),
expected,
"{message}"
);
}
}
#[tokio::test]
async fn thread_start_params_include_cwd_for_embedded_sessions() {
let temp_dir = tempfile::tempdir().expect("tempdir");
+2
View File
@@ -115,6 +115,8 @@ use codex_app_server_protocol::SkillsListResponse;
use codex_app_server_protocol::ThreadGoal as AppThreadGoal;
use codex_app_server_protocol::ThreadGoalStatus as AppThreadGoalStatus;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadSettings;
use codex_app_server_protocol::ThreadSettingsUpdatedNotification;
use codex_app_server_protocol::ThreadTokenUsage;
use codex_app_server_protocol::ToolRequestUserInputParams;
use codex_app_server_protocol::Turn;
+1 -1
View File
@@ -173,7 +173,7 @@ impl ChatWidget {
);
return;
}
self.set_collaboration_mask(collaboration_mode);
self.set_collaboration_mask_from_user_action(collaboration_mode);
let should_queue = self.is_plan_streaming_in_tui();
let user_message = UserMessage {
text,
+8
View File
@@ -51,6 +51,9 @@ impl ChatWidget {
ServerNotification::ThreadGoalCleared(notification) => {
self.on_thread_goal_cleared(notification.thread_id.as_str());
}
ServerNotification::ThreadSettingsUpdated(notification) => {
self.on_thread_settings_updated(notification);
}
ServerNotification::TurnStarted(notification) => {
self.turn_lifecycle.last_turn_id = Some(notification.turn.id);
self.last_non_retry_error = None;
@@ -217,6 +220,7 @@ impl ChatWidget {
| ServerNotification::AccountRateLimitsUpdated(_)
| ServerNotification::ThreadStarted(_)
| ServerNotification::ThreadStatusChanged(_)
| ServerNotification::ThreadSettingsUpdated(_)
| ServerNotification::ThreadArchived(_)
| ServerNotification::ThreadUnarchived(_)
| ServerNotification::RawResponseItemCompleted(_)
@@ -246,6 +250,10 @@ impl ChatWidget {
notification: TurnCompletedNotification,
replay_kind: Option<ReplayKind>,
) {
// User-message dedupe only suppresses the app-server echo of a prompt
// this TUI already rendered locally. Once that turn ends, another
// client can submit the same text and it still needs its own user cell.
self.last_rendered_user_message_display = None;
match notification.turn.status {
TurnStatus::Completed => {
self.last_non_retry_error = None;
+20 -6
View File
@@ -71,18 +71,31 @@ impl ChatWidget {
}
}
self.config.approvals_reviewer = session.approvals_reviewer;
self.config.personality = session.personality;
self.status_line_project_root_name_cache = None;
let forked_from_id = session.forked_from_id;
let model_for_header = session.model.clone();
self.session_header.set_model(&model_for_header);
let default_model = session.model.clone();
self.current_collaboration_mode = self.current_collaboration_mode.with_updates(
Some(model_for_header.clone()),
Some(default_model.clone()),
Some(session.reasoning_effort),
/*developer_instructions*/ None,
);
if let Some(mask) = self.active_collaboration_mask.as_mut() {
mask.model = Some(model_for_header.clone());
mask.reasoning_effort = Some(session.reasoning_effort);
match session.collaboration_mode.as_deref() {
Some(collaboration_mode) => {
self.set_effective_collaboration_mode(collaboration_mode.clone());
}
None => {
self.active_collaboration_mask = Self::initial_collaboration_mask(
&self.config,
self.model_catalog.as_ref(),
Some(&default_model),
);
if let Some(mask) = self.active_collaboration_mask.as_mut() {
mask.reasoning_effort = Some(session.reasoning_effort);
}
self.update_collaboration_mode_indicator();
self.refresh_plan_mode_nudge();
}
}
self.refresh_model_display();
self.refresh_status_surfaces();
@@ -91,6 +104,7 @@ impl ChatWidget {
self.sync_plugins_command_enabled();
self.sync_goal_command_enabled();
self.refresh_plugin_mentions();
let model_for_header = self.current_model().to_string();
if display == SessionConfiguredDisplay::Normal {
let startup_tooltip_override = self.startup_tooltip_override.take();
let show_fast_status = self
+138 -2
View File
@@ -1,6 +1,7 @@
//! Runtime settings state and model/collaboration coordination for `ChatWidget`.
use super::*;
use crate::app_event::AppEvent;
impl ChatWidget {
/// Set the approval policy in the widget's config copy.
@@ -345,6 +346,24 @@ impl ChatWidget {
self.effective_reasoning_effort()
}
pub(crate) fn on_thread_settings_updated(
&mut self,
notification: ThreadSettingsUpdatedNotification,
) {
let Ok(thread_id) = ThreadId::from_string(&notification.thread_id) else {
tracing::warn!(
thread_id = notification.thread_id,
"ignoring app-server ThreadSettingsUpdated with invalid thread_id"
);
return;
};
if self.thread_id != Some(thread_id) {
return;
}
self.apply_thread_settings(notification.thread_settings);
}
#[cfg(test)]
pub(crate) fn active_collaboration_mode_kind(&self) -> ModeKind {
self.active_mode_kind()
@@ -430,7 +449,7 @@ impl ChatWidget {
.unwrap_or(current_effort)
}
pub(super) fn effective_collaboration_mode(&self) -> CollaborationMode {
pub(crate) fn effective_collaboration_mode(&self) -> CollaborationMode {
if !self.collaboration_modes_enabled() {
return self.current_collaboration_mode.clone();
}
@@ -462,6 +481,96 @@ impl ChatWidget {
self.refresh_status_line();
}
fn apply_thread_settings(&mut self, mut settings: ThreadSettings) {
let cwd_changed = self.config.cwd != settings.cwd;
self.apply_thread_settings_cwd(settings.cwd.clone());
self.config.model_provider_id = settings.model_provider.clone();
self.set_service_tier(settings.service_tier.clone());
self.set_approval_policy(settings.approval_policy);
self.set_approvals_reviewer(settings.approvals_reviewer.to_core());
self.config.personality = settings.personality;
let permission_profile = PermissionProfile::from_legacy_sandbox_policy_for_cwd(
&settings.sandbox_policy.to_core(),
settings.cwd.as_path(),
);
let permission_snapshot = PermissionProfileSnapshot::from_session_snapshot(
permission_profile,
settings.active_permission_profile.take().map(Into::into),
);
if let Err(err) = self
.config
.permissions
.set_permission_profile_from_session_snapshot(permission_snapshot.clone())
{
tracing::warn!(%err, "failed to sync permissions from ThreadSettingsUpdated");
if let Err(replace_err) = self
.config
.permissions
.replace_permission_profile_from_session_snapshot(permission_snapshot)
{
tracing::error!(
%replace_err,
"failed to replace permissions from ThreadSettingsUpdated after constraint fallback"
);
}
}
settings.collaboration_mode.settings.model = settings.model;
settings.collaboration_mode.settings.reasoning_effort = settings.effort;
self.set_effective_collaboration_mode(settings.collaboration_mode);
self.refresh_status_surfaces();
self.sync_service_tier_commands();
self.sync_personality_command_enabled();
if cwd_changed {
self.refresh_skills_for_current_cwd(/*force_reload*/ true);
}
self.refresh_plugin_mentions();
self.request_redraw();
}
fn apply_thread_settings_cwd(&mut self, cwd: AbsolutePathBuf) {
let previous_cwd = std::mem::replace(&mut self.config.cwd, cwd.clone());
self.current_cwd = Some(cwd.to_path_buf());
self.status_line_project_root_name_cache = None;
if !self.config.workspace_roots.contains(&previous_cwd) {
return;
}
let previous_roots = std::mem::take(&mut self.config.workspace_roots);
self.config.workspace_roots.push(cwd);
for root in previous_roots {
if root != previous_cwd && !self.config.workspace_roots.contains(&root) {
self.config.workspace_roots.push(root);
}
}
self.config
.permissions
.set_workspace_roots(self.config.workspace_roots.clone());
}
pub(super) fn set_effective_collaboration_mode(&mut self, mode: CollaborationMode) {
let mode_kind = mode.mode;
let settings = mode.settings;
if mode_kind == ModeKind::Default {
self.current_collaboration_mode = CollaborationMode {
mode: ModeKind::Default,
settings: settings.clone(),
};
}
self.active_collaboration_mask = Some(CollaborationModeMask {
name: mode_kind.display_name().to_string(),
mode: Some(mode_kind),
model: Some(settings.model.clone()),
reasoning_effort: Some(settings.reasoning_effort),
developer_instructions: Some(settings.developer_instructions),
});
self.update_collaboration_mode_indicator();
self.refresh_plan_mode_nudge();
self.refresh_model_dependent_surfaces();
}
pub(super) fn model_display_name(&self) -> &str {
let model = self.current_model();
if model.is_empty() {
@@ -555,10 +664,15 @@ impl ChatWidget {
self.model_catalog.as_ref(),
self.active_collaboration_mask.as_ref(),
) {
self.set_collaboration_mask(next_mask);
self.set_collaboration_mask_from_user_action(next_mask);
}
}
pub(crate) fn set_collaboration_mask_from_user_action(&mut self, mask: CollaborationModeMask) {
self.set_collaboration_mask(mask);
self.submit_collaboration_mode_settings_update();
}
/// Update the active collaboration mask.
///
/// When collaboration modes are enabled and a preset is selected,
@@ -609,4 +723,26 @@ impl ChatWidget {
}
self.request_redraw();
}
fn submit_collaboration_mode_settings_update(&self) {
let Some(thread_id) = self.thread_id else {
return;
};
self.app_event_tx.send(AppEvent::SubmitThreadOp {
thread_id,
op: AppCommand::override_turn_context(
/*cwd*/ None,
/*approval_policy*/ None,
/*approvals_reviewer*/ None,
/*active_permission_profile*/ None,
/*windows_sandbox_level*/ None,
/*model*/ None,
/*effort*/ None,
/*summary*/ None,
/*service_tier*/ None,
Some(self.effective_collaboration_mode()),
/*personality*/ None,
),
});
}
}
@@ -88,7 +88,7 @@ impl ChatWidget {
return false;
}
if let Some(mask) = collaboration_modes::plan_mask(self.model_catalog.as_ref()) {
self.set_collaboration_mask(mask);
self.set_collaboration_mask_from_user_action(mask);
true
} else {
self.add_info_message(
@@ -1,6 +1,65 @@
use super::*;
use pretty_assertions::assert_eq;
fn thread_settings_for_test(
model: &str,
thread_id: ThreadId,
) -> codex_app_server_protocol::ThreadSettingsUpdatedNotification {
codex_app_server_protocol::ThreadSettingsUpdatedNotification {
thread_id: thread_id.to_string(),
thread_settings: codex_app_server_protocol::ThreadSettings {
cwd: test_path_buf("/tmp/thread-settings").abs(),
approval_policy: AskForApproval::OnRequest,
approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::AutoReview,
sandbox_policy: codex_app_server_protocol::SandboxPolicy::ReadOnly {
network_access: false,
},
active_permission_profile: Some(
codex_app_server_protocol::ActivePermissionProfile::read_only(),
),
model: model.to_string(),
model_provider: "openai".to_string(),
service_tier: Some(ServiceTier::Fast.request_value().to_string()),
effort: Some(ReasoningEffortConfig::High),
summary: None,
collaboration_mode: CollaborationMode {
mode: ModeKind::Plan,
settings: codex_protocol::config_types::Settings {
model: model.to_string(),
reasoning_effort: Some(ReasoningEffortConfig::High),
developer_instructions: None,
},
},
personality: Some(Personality::Pragmatic),
},
}
}
fn configured_thread_session(thread_id: ThreadId) -> crate::session_state::ThreadSessionState {
crate::session_state::ThreadSessionState {
thread_id,
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
model: "gpt-5.3-codex".to_string(),
model_provider_id: "openai".to_string(),
service_tier: None,
approval_policy: AskForApproval::Never,
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_path_buf("/tmp/thread-settings").abs(),
runtime_workspace_roots: vec![test_path_buf("/tmp/thread-settings").abs()],
instruction_source_paths: Vec::new(),
reasoning_effort: None,
collaboration_mode: None,
personality: None,
message_history: None,
network_proxy: None,
rollout_path: None,
}
}
#[tokio::test]
async fn invalid_url_elicitation_is_declined() {
let (mut chat, _app_event_tx, mut rx, _op_rx) = make_chatwidget_manual_with_sender().await;
@@ -38,6 +97,98 @@ async fn invalid_url_elicitation_is_declined() {
);
}
#[tokio::test]
async fn thread_settings_updated_updates_visible_state_without_transcript() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
set_fast_mode_test_catalog(&mut chat);
let thread_id = ThreadId::new();
chat.handle_thread_session(configured_thread_session(thread_id));
let _ = drain_insert_history(&mut rx);
chat.handle_server_notification(
ServerNotification::ThreadSettingsUpdated(thread_settings_for_test("gpt-5.4", thread_id)),
/*replay_kind*/ None,
);
assert_eq!(chat.current_model(), "gpt-5.4");
assert_eq!(
chat.current_reasoning_effort(),
Some(ReasoningEffortConfig::High)
);
assert_eq!(
chat.current_service_tier(),
Some(ServiceTier::Fast.request_value())
);
assert_eq!(
chat.config_ref().permissions.approval_policy.value(),
AskForApproval::OnRequest.to_core()
);
assert_eq!(
chat.config_ref().approvals_reviewer,
ApprovalsReviewer::AutoReview
);
assert_eq!(
chat.config_ref()
.permissions
.active_permission_profile()
.expect("active profile")
.id,
codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY
);
assert_eq!(chat.config_ref().personality, Some(Personality::Pragmatic));
assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan);
assert!(
drain_insert_history(&mut rx).is_empty(),
"ThreadSettingsUpdated should not render transcript history"
);
chat.handle_server_notification(
ServerNotification::ThreadSettingsUpdated(thread_settings_for_test(
"gpt-5.2",
ThreadId::new(),
)),
/*replay_kind*/ None,
);
assert_eq!(chat.current_model(), "gpt-5.4");
}
#[tokio::test]
async fn thread_settings_updated_preserves_default_settings_for_plan_mode() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
let thread_id = ThreadId::new();
let mut session = configured_thread_session(thread_id);
session.model = "gpt-default".to_string();
session.reasoning_effort = Some(ReasoningEffortConfig::Low);
chat.handle_thread_session(session);
let _ = drain_insert_history(&mut rx);
let default_mode = chat.current_collaboration_mode().clone();
chat.handle_server_notification(
ServerNotification::ThreadSettingsUpdated(thread_settings_for_test("gpt-plan", thread_id)),
/*replay_kind*/ None,
);
assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan);
assert_eq!(chat.current_model(), "gpt-plan");
assert_eq!(
chat.current_reasoning_effort(),
Some(ReasoningEffortConfig::High)
);
assert_eq!(chat.current_collaboration_mode(), &default_mode);
let default_mask = collaboration_modes::default_mask(chat.model_catalog.as_ref())
.expect("expected default collaboration mode");
chat.set_collaboration_mask(default_mask);
assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Default);
assert_eq!(chat.current_model(), "gpt-default");
assert_eq!(
chat.current_reasoning_effort(),
Some(ReasoningEffortConfig::Low)
);
}
#[tokio::test]
async fn collab_spawn_end_shows_requested_model_and_effort() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -30,6 +30,8 @@ async fn submission_preserves_text_elements_and_local_images() {
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
collaboration_mode: None,
personality: None,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
@@ -135,6 +137,8 @@ async fn submission_includes_configured_active_permission_profile() {
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
collaboration_mode: None,
personality: None,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
@@ -188,6 +192,8 @@ async fn submission_omits_active_permission_profile_for_legacy_snapshot() {
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
collaboration_mode: None,
personality: None,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
@@ -231,6 +237,8 @@ async fn submission_with_remote_and_local_images_keeps_local_placeholder_numberi
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
collaboration_mode: None,
personality: None,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
@@ -327,6 +335,8 @@ async fn enter_with_only_remote_images_submits_user_turn() {
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
collaboration_mode: None,
personality: None,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
@@ -392,6 +402,8 @@ async fn shift_enter_with_only_remote_images_does_not_submit_user_turn() {
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
collaboration_mode: None,
personality: None,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
@@ -431,6 +443,8 @@ async fn enter_with_only_remote_images_does_not_submit_when_modal_is_active() {
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
collaboration_mode: None,
personality: None,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
@@ -470,6 +484,8 @@ async fn enter_with_only_remote_images_does_not_submit_when_input_disabled() {
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
collaboration_mode: None,
personality: None,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
@@ -512,6 +528,8 @@ async fn submission_prefers_selected_duplicate_skill_path() {
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
collaboration_mode: None,
personality: None,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
@@ -976,6 +976,8 @@ async fn bang_shell_enter_while_task_running_submits_run_user_shell_command() {
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
collaboration_mode: None,
personality: None,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
@@ -31,6 +31,8 @@ async fn resumed_initial_messages_render_history() {
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
collaboration_mode: None,
personality: None,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
@@ -102,6 +104,8 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() {
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
collaboration_mode: None,
personality: None,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
@@ -172,6 +176,8 @@ async fn replayed_user_message_preserves_remote_image_urls() {
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
collaboration_mode: None,
personality: None,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
@@ -272,6 +278,8 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() {
runtime_workspace_roots: vec![expected_cwd.clone()],
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
collaboration_mode: None,
personality: None,
message_history: None,
network_proxy: None,
rollout_path: None,
@@ -343,6 +351,8 @@ async fn session_configured_preserves_profile_workspace_roots() {
runtime_workspace_roots: session_runtime_workspace_roots.clone(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
collaboration_mode: None,
personality: None,
message_history: None,
network_proxy: None,
rollout_path: None,
@@ -388,6 +398,8 @@ async fn session_configured_external_sandbox_keeps_external_runtime_policy() {
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
collaboration_mode: None,
personality: None,
message_history: None,
network_proxy: None,
rollout_path: None,
@@ -427,6 +439,8 @@ async fn replayed_user_message_with_only_remote_images_renders_history_cell() {
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
collaboration_mode: None,
personality: None,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
@@ -483,6 +497,8 @@ async fn replayed_user_message_with_only_local_images_renders_history_cell() {
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
collaboration_mode: None,
personality: None,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
@@ -755,6 +771,8 @@ async fn replayed_reasoning_item_hides_raw_reasoning_when_disabled() {
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
collaboration_mode: None,
personality: None,
message_history: None,
network_proxy: None,
rollout_path: None,
@@ -801,6 +819,8 @@ async fn replayed_reasoning_item_shows_raw_reasoning_when_enabled() {
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
collaboration_mode: None,
personality: None,
message_history: None,
network_proxy: None,
rollout_path: None,
@@ -584,6 +584,8 @@ async fn permissions_selection_marks_auto_review_current_after_session_configure
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
collaboration_mode: None,
personality: None,
message_history: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
@@ -632,6 +634,8 @@ async fn permissions_selection_marks_auto_review_current_with_custom_workspace_w
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
collaboration_mode: None,
personality: None,
message_history: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
@@ -1220,6 +1220,8 @@ async fn submit_user_message_emits_structured_plugin_mentions_from_bindings() {
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
collaboration_mode: None,
personality: None,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
@@ -1407,6 +1409,8 @@ async fn plan_slash_command_with_args_submits_prompt_in_plan_mode() {
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
collaboration_mode: None,
personality: None,
message_history: None,
network_proxy: None,
rollout_path: None,
@@ -2493,6 +2493,8 @@ async fn session_configured_clears_goal_status_footer() {
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
collaboration_mode: None,
personality: None,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
+2
View File
@@ -451,6 +451,8 @@ fn session_configured_event(model: &str) -> ThreadSessionState {
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
collaboration_mode: None,
personality: None,
message_history: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
+4
View File
@@ -7,6 +7,8 @@ use std::path::PathBuf;
use codex_app_server_protocol::AskForApproval;
use codex_protocol::ThreadId;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::Personality;
use codex_protocol::models::ActivePermissionProfile;
use codex_protocol::models::PermissionProfile;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -47,6 +49,8 @@ pub(crate) struct ThreadSessionState {
pub(crate) runtime_workspace_roots: Vec<AbsolutePathBuf>,
pub(crate) instruction_source_paths: Vec<AbsolutePathBuf>,
pub(crate) reasoning_effort: Option<codex_protocol::openai_models::ReasoningEffort>,
pub(crate) collaboration_mode: Option<Box<CollaborationMode>>,
pub(crate) personality: Option<Personality>,
pub(crate) message_history: Option<MessageHistoryMetadata>,
pub(crate) network_proxy: Option<SessionNetworkProxyRuntime>,
pub(crate) rollout_path: Option<PathBuf>,
+5 -4
View File
@@ -50,9 +50,11 @@ def parse_args() -> argparse.Namespace:
parser.add_argument(
"--archive-output",
type=Path,
action="append",
default=[],
help=(
"Optional archive output path. Supported suffixes: .tar.gz, .tgz, "
".tar.zst, .zip."
"Optional archive output path. May be repeated. Supported suffixes: "
".tar.gz, .tgz, .tar.zst, .zip."
),
)
parser.add_argument(
@@ -130,8 +132,7 @@ def main() -> int:
build_package_dir(package_dir, version, variant, spec, inputs)
validate_package_dir(package_dir, variant, spec)
archive_output = args.archive_output
if archive_output is not None:
for archive_output in args.archive_output:
archive_path = archive_output.resolve()
write_archive(package_dir, archive_path, force=args.force)
print(f"Built Codex package archive at {archive_path}")
+19 -3
View File
@@ -1,8 +1,6 @@
#!/usr/bin/env python3
"""Stage one or more Codex npm packages for release."""
from __future__ import annotations
import argparse
import importlib.util
import json
@@ -68,6 +66,15 @@ def parse_args() -> argparse.Namespace:
"Intended for CI compatibility only; release staging should not use this."
),
)
parser.add_argument(
"--allow-legacy-codex-package",
action="store_true",
help=(
"Allow codex-package layouts to be synthesized from legacy per-binary "
"workflow artifacts. Intended for CI compatibility only; release staging "
"should not use this."
),
)
return parser.parse_args()
@@ -124,11 +131,15 @@ def install_native_components(
workflow_url: str,
components: set[str],
vendor_root: Path,
*,
allow_legacy_codex_package: bool,
) -> None:
if not components:
return
cmd = [str(INSTALL_NATIVE_DEPS), "--workflow-url", workflow_url]
if allow_legacy_codex_package:
cmd.append("--allow-legacy-codex-package")
for component in sorted(components):
cmd.extend(["--component", component])
cmd.append(str(vendor_root))
@@ -172,7 +183,12 @@ def main() -> int:
args.release_version, args.workflow_url
)
vendor_temp_root = Path(tempfile.mkdtemp(prefix="npm-native-", dir=runner_temp))
install_native_components(workflow_url, native_components_to_install, vendor_temp_root)
install_native_components(
workflow_url,
native_components_to_install,
vendor_temp_root,
allow_legacy_codex_package=args.allow_legacy_codex_package,
)
vendor_src = vendor_temp_root / "vendor"
if resolved_head_sha: