mirror of
https://github.com/openai/codex.git
synced 2026-11-08 20:40:33 +00:00
Merge 167a3c19e1 into sapling-pr-archive-bolinfest
This commit is contained in:
@@ -153,6 +153,20 @@ common:ci-macos --config=remote
|
||||
common:ci-macos --strategy=remote
|
||||
common:ci-macos --strategy=TestRunner=darwin-sandbox,local
|
||||
|
||||
# On Windows, use Linux remote execution for build actions but keep test actions
|
||||
# on the Windows runner so Bazel's normal test sharding and flaky-test retries
|
||||
# still run against Windows binaries.
|
||||
common:ci-windows-cross --config=ci-windows
|
||||
common:ci-windows-cross --build_metadata=TAG_windows_cross_compile=true
|
||||
common:ci-windows-cross --config=remote
|
||||
common:ci-windows-cross --host_platform=//:rbe
|
||||
common:ci-windows-cross --strategy=remote
|
||||
common:ci-windows-cross --strategy=TestRunner=local
|
||||
common:ci-windows-cross --local_test_jobs=8
|
||||
common:ci-windows-cross --platforms=//:windows_x86_64_gnullvm
|
||||
common:ci-windows-cross --extra_execution_platforms=//:rbe,//:windows_x86_64_msvc
|
||||
common:ci-windows-cross --extra_toolchains=//:windows_gnullvm_tests_on_msvc_host_toolchain
|
||||
|
||||
# Linux-only V8 CI config.
|
||||
common:ci-v8 --config=ci
|
||||
common:ci-v8 --build_metadata=TAG_workflow=v8
|
||||
|
||||
@@ -5,9 +5,9 @@ tool entries, such as Maven, that can change independently of this repo and
|
||||
cause avoidable cache misses.
|
||||
|
||||
This script derives a smaller, cache-stable PATH that keeps the Windows
|
||||
toolchain entries Bazel-backed CI tasks need: MSVC and Windows SDK paths, Git,
|
||||
PowerShell, Node, Python, DotSlash, and the standard Windows system
|
||||
directories.
|
||||
toolchain entries Bazel-backed CI tasks need: MSVC and Windows SDK paths,
|
||||
MinGW runtime DLL paths for gnullvm-built tests, Git, PowerShell, Node, Python,
|
||||
DotSlash, and the standard Windows system directories.
|
||||
`setup-bazel-ci` runs this after exporting the MSVC environment, and the script
|
||||
publishes the result via `GITHUB_ENV` as `CODEX_BAZEL_WINDOWS_PATH` so later
|
||||
steps can pass that explicit PATH to Bazel.
|
||||
@@ -49,6 +49,8 @@ foreach ($pathEntry in ($env:PATH -split ';')) {
|
||||
$pathEntry -like '*Microsoft Visual Studio*' -or
|
||||
$pathEntry -like '*Windows Kits*' -or
|
||||
$pathEntry -like '*Microsoft SDKs*' -or
|
||||
$pathEntry -eq 'C:\mingw64\bin' -or
|
||||
$pathEntry -like 'C:\msys64\*\bin' -or
|
||||
$pathEntry -like 'C:\Program Files\Git\*' -or
|
||||
$pathEntry -like 'C:\Program Files\PowerShell\*' -or
|
||||
$pathEntry -like 'C:\hostedtoolcache\windows\node\*' -or
|
||||
@@ -85,6 +87,12 @@ if ($pwshCommand) {
|
||||
Add-StablePathEntry (Split-Path $pwshCommand.Source -Parent)
|
||||
}
|
||||
|
||||
foreach ($mingwPath in @('C:\mingw64\bin', 'C:\msys64\mingw64\bin', 'C:\msys64\ucrt64\bin')) {
|
||||
if (Test-Path $mingwPath) {
|
||||
Add-StablePathEntry $mingwPath
|
||||
}
|
||||
}
|
||||
|
||||
if ($windowsAppsPath) {
|
||||
Add-StablePathEntry $windowsAppsPath
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ print_failed_bazel_test_logs=0
|
||||
print_failed_bazel_action_summary=0
|
||||
remote_download_toplevel=0
|
||||
windows_msvc_host_platform=0
|
||||
windows_cross_compile=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
@@ -25,6 +26,10 @@ while [[ $# -gt 0 ]]; do
|
||||
windows_msvc_host_platform=1
|
||||
shift
|
||||
;;
|
||||
--windows-cross-compile)
|
||||
windows_cross_compile=1
|
||||
shift
|
||||
;;
|
||||
--)
|
||||
shift
|
||||
break
|
||||
@@ -37,7 +42,7 @@ while [[ $# -gt 0 ]]; do
|
||||
done
|
||||
|
||||
if [[ $# -eq 0 ]]; then
|
||||
echo "Usage: $0 [--print-failed-test-logs] [--print-failed-action-summary] [--remote-download-toplevel] [--windows-msvc-host-platform] -- <bazel args> -- <targets>" >&2
|
||||
echo "Usage: $0 [--print-failed-test-logs] [--print-failed-action-summary] [--remote-download-toplevel] [--windows-msvc-host-platform] [--windows-cross-compile] -- <bazel args> -- <targets>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -61,7 +66,11 @@ case "${RUNNER_OS:-}" in
|
||||
ci_config=ci-macos
|
||||
;;
|
||||
Windows)
|
||||
ci_config=ci-windows
|
||||
if [[ $windows_cross_compile -eq 1 ]]; then
|
||||
ci_config=ci-windows-cross
|
||||
else
|
||||
ci_config=ci-windows
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -105,8 +114,8 @@ print_bazel_test_log_tails() {
|
||||
while IFS= read -r target; do
|
||||
failed_targets+=("$target")
|
||||
done < <(
|
||||
grep -E '^FAIL: //' "$console_log" \
|
||||
| sed -E 's#^FAIL: (//[^ ]+).*#\1#' \
|
||||
grep -E '^(FAIL: //|ERROR: .* Testing //)' "$console_log" \
|
||||
| sed -E 's#^FAIL: (//[^ ]+).*#\1#; s#^ERROR: .* Testing (//[^ ]+) failed:.*#\1#' \
|
||||
| sort -u
|
||||
)
|
||||
|
||||
@@ -244,6 +253,12 @@ if [[ ${#bazel_args[@]} -eq 0 || ${#bazel_targets[@]} -eq 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${RUNNER_OS:-}" == "Windows" && $windows_cross_compile -eq 1 && -z "${BUILDBUDDY_API_KEY:-}" ]]; then
|
||||
# Fork PRs do not receive the BuildBuddy secret needed for the remote
|
||||
# cross-compile config. Preserve the previous local Windows build shape.
|
||||
windows_msvc_host_platform=1
|
||||
fi
|
||||
|
||||
post_config_bazel_args=()
|
||||
if [[ "${RUNNER_OS:-}" == "Windows" && $windows_msvc_host_platform -eq 1 ]]; then
|
||||
has_host_platform_override=0
|
||||
@@ -269,6 +284,25 @@ if [[ $remote_download_toplevel -eq 1 ]]; then
|
||||
post_config_bazel_args+=(--remote_download_toplevel)
|
||||
fi
|
||||
|
||||
if [[ "${RUNNER_OS:-}" == "Windows" && $windows_cross_compile -eq 1 && -n "${BUILDBUDDY_API_KEY:-}" ]]; then
|
||||
# `--enable_platform_specific_config` expands `common:windows` on Windows
|
||||
# hosts after ordinary rc configs, which can override `ci-windows-cross`'s
|
||||
# RBE host platform. Repeat the host platform on the command line so V8 and
|
||||
# other genrules execute on Linux RBE workers instead of Git Bash locally.
|
||||
#
|
||||
# Bazel also derives the default genrule shell from the client host. Without
|
||||
# an explicit shell executable, remote Linux actions can be asked to run
|
||||
# `C:\Program Files\Git\usr\bin\bash.exe`.
|
||||
post_config_bazel_args+=(--host_platform=//:rbe --shell_executable=/bin/bash)
|
||||
fi
|
||||
|
||||
if [[ "${RUNNER_OS:-}" == "Windows" && $windows_cross_compile -eq 1 && -z "${BUILDBUDDY_API_KEY:-}" ]]; then
|
||||
# The Windows cross-compile config depends on remote execution. Fork PRs do
|
||||
# not receive the BuildBuddy secret, so fall back to the existing local build
|
||||
# shape and keep its lower concurrency cap.
|
||||
post_config_bazel_args+=(--jobs=8)
|
||||
fi
|
||||
|
||||
if [[ -n "${BAZEL_REPO_CONTENTS_CACHE:-}" ]]; then
|
||||
# Windows self-hosted runners can run multiple Bazel jobs concurrently. Give
|
||||
# each job its own repo contents cache so they do not fight over the shared
|
||||
@@ -287,37 +321,57 @@ if [[ -n "${CODEX_BAZEL_EXECUTION_LOG_COMPACT_DIR:-}" ]]; then
|
||||
fi
|
||||
|
||||
if [[ "${RUNNER_OS:-}" == "Windows" ]]; then
|
||||
windows_action_env_vars=(
|
||||
INCLUDE
|
||||
LIB
|
||||
LIBPATH
|
||||
UCRTVersion
|
||||
UniversalCRTSdkDir
|
||||
VCINSTALLDIR
|
||||
VCToolsInstallDir
|
||||
WindowsLibPath
|
||||
WindowsSdkBinPath
|
||||
WindowsSdkDir
|
||||
WindowsSDKLibVersion
|
||||
WindowsSDKVersion
|
||||
)
|
||||
pass_windows_build_env=1
|
||||
if [[ $windows_cross_compile -eq 1 && -n "${BUILDBUDDY_API_KEY:-}" ]]; then
|
||||
# Remote build actions execute on Linux RBE workers. Passing the Windows
|
||||
# runner's build environment there makes Bazel genrules try to execute
|
||||
# C:\Program Files\Git\usr\bin\bash.exe on Linux.
|
||||
pass_windows_build_env=0
|
||||
fi
|
||||
|
||||
for env_var in "${windows_action_env_vars[@]}"; do
|
||||
if [[ -n "${!env_var:-}" ]]; then
|
||||
post_config_bazel_args+=("--action_env=${env_var}" "--host_action_env=${env_var}")
|
||||
fi
|
||||
done
|
||||
if [[ $pass_windows_build_env -eq 1 ]]; then
|
||||
windows_action_env_vars=(
|
||||
INCLUDE
|
||||
LIB
|
||||
LIBPATH
|
||||
UCRTVersion
|
||||
UniversalCRTSdkDir
|
||||
VCINSTALLDIR
|
||||
VCToolsInstallDir
|
||||
WindowsLibPath
|
||||
WindowsSdkBinPath
|
||||
WindowsSdkDir
|
||||
WindowsSDKLibVersion
|
||||
WindowsSDKVersion
|
||||
)
|
||||
|
||||
for env_var in "${windows_action_env_vars[@]}"; do
|
||||
if [[ -n "${!env_var:-}" ]]; then
|
||||
post_config_bazel_args+=("--action_env=${env_var}" "--host_action_env=${env_var}")
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ -z "${CODEX_BAZEL_WINDOWS_PATH:-}" ]]; then
|
||||
echo "CODEX_BAZEL_WINDOWS_PATH must be set for Windows Bazel CI." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
post_config_bazel_args+=(
|
||||
"--action_env=PATH=${CODEX_BAZEL_WINDOWS_PATH}"
|
||||
"--host_action_env=PATH=${CODEX_BAZEL_WINDOWS_PATH}"
|
||||
"--test_env=PATH=${CODEX_BAZEL_WINDOWS_PATH}"
|
||||
)
|
||||
if [[ $pass_windows_build_env -eq 1 ]]; then
|
||||
post_config_bazel_args+=(
|
||||
"--action_env=PATH=${CODEX_BAZEL_WINDOWS_PATH}"
|
||||
"--host_action_env=PATH=${CODEX_BAZEL_WINDOWS_PATH}"
|
||||
)
|
||||
elif [[ $windows_cross_compile -eq 1 ]]; then
|
||||
# Remote build actions run on Linux RBE workers. Give their shell snippets
|
||||
# a Linux PATH while preserving CODEX_BAZEL_WINDOWS_PATH below for local
|
||||
# Windows test execution.
|
||||
post_config_bazel_args+=(
|
||||
"--action_env=PATH=/usr/bin:/bin"
|
||||
"--host_action_env=PATH=/usr/bin:/bin"
|
||||
)
|
||||
fi
|
||||
post_config_bazel_args+=("--test_env=PATH=${CODEX_BAZEL_WINDOWS_PATH}")
|
||||
fi
|
||||
|
||||
bazel_console_log="$(mktemp)"
|
||||
|
||||
@@ -22,8 +22,6 @@ jobs:
|
||||
# true test failure in a rust_test() marked "flaky" that gets run 3x.
|
||||
# In that case, extra time generally does not give us more signal.
|
||||
#
|
||||
# Ultimately we need true distributed builds (e.g.,
|
||||
# https://www.buildbuddy.io/docs/rbe-setup/) to speed things up.
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -53,7 +51,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
# Configure a human readable name for each job
|
||||
name: Local Bazel build on ${{ matrix.os }} for ${{ matrix.target }}
|
||||
name: Bazel test on ${{ matrix.os }} for ${{ matrix.target }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
@@ -91,6 +89,7 @@ jobs:
|
||||
)
|
||||
|
||||
bazel_wrapper_args=(
|
||||
--print-failed-action-summary
|
||||
--print-failed-test-logs
|
||||
)
|
||||
bazel_test_args=(
|
||||
@@ -100,8 +99,10 @@ jobs:
|
||||
--build_metadata=COMMIT_SHA=${GITHUB_SHA}
|
||||
)
|
||||
if [[ "${RUNNER_OS}" == "Windows" ]]; then
|
||||
bazel_wrapper_args+=(--windows-msvc-host-platform)
|
||||
bazel_test_args+=(--jobs=8)
|
||||
bazel_wrapper_args+=(
|
||||
--windows-cross-compile
|
||||
--remote-download-toplevel
|
||||
)
|
||||
fi
|
||||
|
||||
./.github/scripts/run-bazel-ci.sh \
|
||||
|
||||
@@ -17,10 +17,10 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
|
||||
uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0
|
||||
|
||||
- name: Run cargo-deny
|
||||
uses: EmbarkStudios/cargo-deny-action@82eb9f621fbc699dd0918f3ea06864c14cc84246 # v2
|
||||
with:
|
||||
rust-version: stable
|
||||
rust-version: 1.93.0
|
||||
manifest-path: ./codex-rs/Cargo.toml
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: dtolnay/rust-toolchain@c2b55edffaf41a251c410bb32bed22afefa800f1 # 1.92
|
||||
- uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0
|
||||
- name: Validate tag matches Cargo.toml version
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
+34
@@ -30,6 +30,40 @@ platform(
|
||||
parents = ["@platforms//host"],
|
||||
)
|
||||
|
||||
platform(
|
||||
name = "windows_x86_64_gnullvm",
|
||||
constraint_values = [
|
||||
"@platforms//cpu:x86_64",
|
||||
"@platforms//os:windows",
|
||||
"@rules_rs//rs/experimental/platforms/constraints:windows_gnullvm",
|
||||
],
|
||||
)
|
||||
|
||||
platform(
|
||||
name = "windows_x86_64_msvc",
|
||||
constraint_values = [
|
||||
"@platforms//cpu:x86_64",
|
||||
"@platforms//os:windows",
|
||||
"@rules_rs//rs/experimental/platforms/constraints:windows_msvc",
|
||||
],
|
||||
)
|
||||
|
||||
toolchain(
|
||||
name = "windows_gnullvm_tests_on_msvc_host_toolchain",
|
||||
exec_compatible_with = [
|
||||
"@platforms//cpu:x86_64",
|
||||
"@platforms//os:windows",
|
||||
"@rules_rs//rs/experimental/platforms/constraints:windows_msvc",
|
||||
],
|
||||
target_compatible_with = [
|
||||
"@platforms//cpu:x86_64",
|
||||
"@platforms//os:windows",
|
||||
"@rules_rs//rs/experimental/platforms/constraints:windows_gnullvm",
|
||||
],
|
||||
toolchain = "@bazel_tools//tools/test:empty_toolchain",
|
||||
toolchain_type = "@bazel_tools//tools/test:default_test_toolchain_type",
|
||||
)
|
||||
|
||||
alias(
|
||||
name = "rbe",
|
||||
actual = "@rbe_platform",
|
||||
|
||||
@@ -6,4 +6,6 @@ ignore = [
|
||||
"RUSTSEC-2024-0436", # paste 1.0.15 via starlark/ratatui; upstream crate is unmaintained
|
||||
"RUSTSEC-2024-0320", # yaml-rust via syntect; remove when syntect drops or updates it
|
||||
"RUSTSEC-2025-0141", # bincode via syntect; remove when syntect drops or updates it
|
||||
"RUSTSEC-2026-0118", # hickory-proto via rama-dns/rama-tcp; remove when rama updates to hickory 0.26.1 or hickory-net
|
||||
"RUSTSEC-2026-0119", # hickory-proto via rama-dns/rama-tcp; remove when rama updates to hickory 0.26.1 or hickory-net
|
||||
]
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ jobs:
|
||||
working-directory: codex-rs
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0
|
||||
- name: Install cargo-audit
|
||||
uses: taiki-e/install-action@v2
|
||||
with:
|
||||
|
||||
@@ -300,7 +300,15 @@ impl fmt::Display for TypedRequestError {
|
||||
write!(f, "{method} transport error: {source}")
|
||||
}
|
||||
Self::Server { method, source } => {
|
||||
write!(f, "{method} failed: {}", source.message)
|
||||
write!(
|
||||
f,
|
||||
"{method} failed: {} (code {})",
|
||||
source.message, source.code
|
||||
)?;
|
||||
if let Some(data) = source.data.as_ref() {
|
||||
write!(f, ", data: {data}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Self::Deserialize { method, source } => {
|
||||
write!(f, "{method} response decode error: {source}")
|
||||
@@ -1915,11 +1923,15 @@ mod tests {
|
||||
method: "thread/read".to_string(),
|
||||
source: JSONRPCErrorError {
|
||||
code: -32603,
|
||||
data: None,
|
||||
data: Some(serde_json::json!({"detail": "config lock mismatch"})),
|
||||
message: "internal".to_string(),
|
||||
},
|
||||
};
|
||||
assert_eq!(std::error::Error::source(&server).is_some(), false);
|
||||
assert_eq!(
|
||||
server.to_string(),
|
||||
"thread/read failed: internal (code -32603), data: {\"detail\":\"config lock mismatch\"}"
|
||||
);
|
||||
|
||||
let deserialize = TypedRequestError::Deserialize {
|
||||
method: "thread/start".to_string(),
|
||||
|
||||
@@ -2217,6 +2217,25 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PluginSkillReadParams": {
|
||||
"properties": {
|
||||
"remoteMarketplaceName": {
|
||||
"type": "string"
|
||||
},
|
||||
"remotePluginId": {
|
||||
"type": "string"
|
||||
},
|
||||
"skillName": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"remoteMarketplaceName",
|
||||
"remotePluginId",
|
||||
"skillName"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PluginUninstallParams": {
|
||||
"properties": {
|
||||
"pluginId": {
|
||||
@@ -5036,6 +5055,30 @@
|
||||
"title": "Plugin/readRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"plugin/skill/read"
|
||||
],
|
||||
"title": "Plugin/skill/readRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/PluginSkillReadParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Plugin/skill/readRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
|
||||
+84
-1
@@ -762,6 +762,30 @@
|
||||
"title": "Plugin/readRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/v2/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"plugin/skill/read"
|
||||
],
|
||||
"title": "Plugin/skill/readRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/v2/PluginSkillReadParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Plugin/skill/readRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -12334,6 +12358,31 @@
|
||||
"title": "PluginShareDeleteResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"PluginShareListItem": {
|
||||
"properties": {
|
||||
"localPluginPath": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/v2/AbsolutePathBuf"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"plugin": {
|
||||
"$ref": "#/definitions/v2/PluginSummary"
|
||||
},
|
||||
"shareUrl": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"plugin",
|
||||
"shareUrl"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PluginShareListParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "PluginShareListParams",
|
||||
@@ -12344,7 +12393,7 @@
|
||||
"properties": {
|
||||
"data": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/v2/PluginSummary"
|
||||
"$ref": "#/definitions/v2/PluginShareListItem"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
@@ -12391,6 +12440,40 @@
|
||||
"title": "PluginShareSaveResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"PluginSkillReadParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"remoteMarketplaceName": {
|
||||
"type": "string"
|
||||
},
|
||||
"remotePluginId": {
|
||||
"type": "string"
|
||||
},
|
||||
"skillName": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"remoteMarketplaceName",
|
||||
"remotePluginId",
|
||||
"skillName"
|
||||
],
|
||||
"title": "PluginSkillReadParams",
|
||||
"type": "object"
|
||||
},
|
||||
"PluginSkillReadResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"contents": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"title": "PluginSkillReadResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"PluginSource": {
|
||||
"oneOf": [
|
||||
{
|
||||
|
||||
+84
-1
@@ -1521,6 +1521,30 @@
|
||||
"title": "Plugin/readRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"plugin/skill/read"
|
||||
],
|
||||
"title": "Plugin/skill/readRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/PluginSkillReadParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Plugin/skill/readRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -8987,6 +9011,31 @@
|
||||
"title": "PluginShareDeleteResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"PluginShareListItem": {
|
||||
"properties": {
|
||||
"localPluginPath": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"plugin": {
|
||||
"$ref": "#/definitions/PluginSummary"
|
||||
},
|
||||
"shareUrl": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"plugin",
|
||||
"shareUrl"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PluginShareListParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "PluginShareListParams",
|
||||
@@ -8997,7 +9046,7 @@
|
||||
"properties": {
|
||||
"data": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/PluginSummary"
|
||||
"$ref": "#/definitions/PluginShareListItem"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
@@ -9044,6 +9093,40 @@
|
||||
"title": "PluginShareSaveResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"PluginSkillReadParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"remoteMarketplaceName": {
|
||||
"type": "string"
|
||||
},
|
||||
"remotePluginId": {
|
||||
"type": "string"
|
||||
},
|
||||
"skillName": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"remoteMarketplaceName",
|
||||
"remotePluginId",
|
||||
"skillName"
|
||||
],
|
||||
"title": "PluginSkillReadParams",
|
||||
"type": "object"
|
||||
},
|
||||
"PluginSkillReadResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"contents": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"title": "PluginSkillReadResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"PluginSource": {
|
||||
"oneOf": [
|
||||
{
|
||||
|
||||
@@ -167,6 +167,31 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PluginShareListItem": {
|
||||
"properties": {
|
||||
"localPluginPath": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"plugin": {
|
||||
"$ref": "#/definitions/PluginSummary"
|
||||
},
|
||||
"shareUrl": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"plugin",
|
||||
"shareUrl"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PluginSource": {
|
||||
"oneOf": [
|
||||
{
|
||||
@@ -304,7 +329,7 @@
|
||||
"properties": {
|
||||
"data": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/PluginSummary"
|
||||
"$ref": "#/definitions/PluginShareListItem"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"remoteMarketplaceName": {
|
||||
"type": "string"
|
||||
},
|
||||
"remotePluginId": {
|
||||
"type": "string"
|
||||
},
|
||||
"skillName": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"remoteMarketplaceName",
|
||||
"remotePluginId",
|
||||
"skillName"
|
||||
],
|
||||
"title": "PluginSkillReadParams",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"contents": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"title": "PluginSkillReadResponse",
|
||||
"type": "object"
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,7 @@
|
||||
// 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 { PluginSummary } from "./PluginSummary";
|
||||
|
||||
export type PluginShareListItem = { plugin: PluginSummary, shareUrl: string, localPluginPath: AbsolutePathBuf | null, };
|
||||
+2
-2
@@ -1,6 +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 { PluginSummary } from "./PluginSummary";
|
||||
import type { PluginShareListItem } from "./PluginShareListItem";
|
||||
|
||||
export type PluginShareListResponse = { data: Array<PluginSummary>, };
|
||||
export type PluginShareListResponse = { data: Array<PluginShareListItem>, };
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type PluginSkillReadParams = { remoteMarketplaceName: string, remotePluginId: string, skillName: string, };
|
||||
@@ -0,0 +1,5 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type PluginSkillReadResponse = { contents: string | null, };
|
||||
@@ -283,10 +283,13 @@ export type { PluginReadParams } from "./PluginReadParams";
|
||||
export type { PluginReadResponse } from "./PluginReadResponse";
|
||||
export type { PluginShareDeleteParams } from "./PluginShareDeleteParams";
|
||||
export type { PluginShareDeleteResponse } from "./PluginShareDeleteResponse";
|
||||
export type { PluginShareListItem } from "./PluginShareListItem";
|
||||
export type { PluginShareListParams } from "./PluginShareListParams";
|
||||
export type { PluginShareListResponse } from "./PluginShareListResponse";
|
||||
export type { PluginShareSaveParams } from "./PluginShareSaveParams";
|
||||
export type { PluginShareSaveResponse } from "./PluginShareSaveResponse";
|
||||
export type { PluginSkillReadParams } from "./PluginSkillReadParams";
|
||||
export type { PluginSkillReadResponse } from "./PluginSkillReadResponse";
|
||||
export type { PluginSource } from "./PluginSource";
|
||||
export type { PluginSummary } from "./PluginSummary";
|
||||
export type { PluginUninstallParams } from "./PluginUninstallParams";
|
||||
|
||||
@@ -612,6 +612,11 @@ client_request_definitions! {
|
||||
serialization: global("config"),
|
||||
response: v2::PluginReadResponse,
|
||||
},
|
||||
PluginSkillRead => "plugin/skill/read" {
|
||||
params: v2::PluginSkillReadParams,
|
||||
serialization: global("config"),
|
||||
response: v2::PluginSkillReadResponse,
|
||||
},
|
||||
PluginShareSave => "plugin/share/save" {
|
||||
params: v2::PluginShareSaveParams,
|
||||
serialization: global("config"),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use crate::protocol::common::ServerNotification;
|
||||
use crate::protocol::item_builders::build_command_execution_begin_item;
|
||||
use crate::protocol::item_builders::build_command_execution_end_item;
|
||||
use crate::protocol::item_builders::build_file_change_begin_item;
|
||||
use crate::protocol::item_builders::convert_patch_changes;
|
||||
use crate::protocol::v2::AgentMessageDeltaNotification;
|
||||
use crate::protocol::v2::CollabAgentState;
|
||||
@@ -450,13 +449,6 @@ pub fn item_event_to_server_notification(
|
||||
item: item_completed_event.item.into(),
|
||||
})
|
||||
}
|
||||
EventMsg::PatchApplyBegin(patch_begin_event) => {
|
||||
ServerNotification::ItemStarted(ItemStartedNotification {
|
||||
thread_id,
|
||||
turn_id,
|
||||
item: build_file_change_begin_item(&patch_begin_event),
|
||||
})
|
||||
}
|
||||
EventMsg::PatchApplyUpdated(event) => {
|
||||
ServerNotification::FileChangePatchUpdated(FileChangePatchUpdatedNotification {
|
||||
thread_id,
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
//! Shared builders for synthetic [`ThreadItem`] values emitted by the app-server layer.
|
||||
//! Shared builders for app-server [`ThreadItem`] values derived from compatibility events.
|
||||
//!
|
||||
//! These items do not come from first-class core `ItemStarted` / `ItemCompleted` events.
|
||||
//! Instead, the app-server synthesizes them so clients can render a coherent lifecycle for
|
||||
//! approvals and other pre-execution flows before the underlying tool has started or when the
|
||||
//! tool never starts at all.
|
||||
//! Most live tool items now come from first-class core `ItemStarted` / `ItemCompleted` events.
|
||||
//! These builders remain for approval flows, rebuilt legacy history, and other pre-execution
|
||||
//! paths where the underlying tool has not started or never starts at all.
|
||||
//!
|
||||
//! Keeping these builders in one place is useful for two reasons:
|
||||
//! - Live notifications and rebuilt `thread/read` history both need to construct the same
|
||||
|
||||
@@ -357,6 +357,7 @@ impl ThreadHistoryBuilder {
|
||||
| codex_protocol::items::TurnItem::Reasoning(_)
|
||||
| codex_protocol::items::TurnItem::WebSearch(_)
|
||||
| codex_protocol::items::TurnItem::ImageGeneration(_)
|
||||
| codex_protocol::items::TurnItem::FileChange(_)
|
||||
| codex_protocol::items::TurnItem::ContextCompaction(_) => {}
|
||||
}
|
||||
}
|
||||
@@ -378,6 +379,7 @@ impl ThreadHistoryBuilder {
|
||||
| codex_protocol::items::TurnItem::Reasoning(_)
|
||||
| codex_protocol::items::TurnItem::WebSearch(_)
|
||||
| codex_protocol::items::TurnItem::ImageGeneration(_)
|
||||
| codex_protocol::items::TurnItem::FileChange(_)
|
||||
| codex_protocol::items::TurnItem::ContextCompaction(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::path::PathBuf;
|
||||
|
||||
use crate::RequestId;
|
||||
use crate::protocol::common::AuthMode;
|
||||
use crate::protocol::item_builders::convert_patch_changes;
|
||||
use codex_experimental_api_macros::ExperimentalApi;
|
||||
use codex_protocol::account::PlanType;
|
||||
use codex_protocol::account::ProviderAccount;
|
||||
@@ -4609,6 +4610,22 @@ pub struct PluginReadResponse {
|
||||
pub plugin: PluginDetail,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct PluginSkillReadParams {
|
||||
pub remote_marketplace_name: String,
|
||||
pub remote_plugin_id: String,
|
||||
pub skill_name: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct PluginSkillReadResponse {
|
||||
pub contents: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
@@ -4635,7 +4652,7 @@ pub struct PluginShareListParams {}
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct PluginShareListResponse {
|
||||
pub data: Vec<PluginSummary>,
|
||||
pub data: Vec<PluginShareListItem>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
@@ -4650,6 +4667,15 @@ pub struct PluginShareDeleteParams {
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct PluginShareDeleteResponse {}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct PluginShareListItem {
|
||||
pub plugin: PluginSummary,
|
||||
pub share_url: String,
|
||||
pub local_plugin_path: Option<AbsolutePathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(rename_all = "snake_case")]
|
||||
@@ -6444,6 +6470,15 @@ impl From<CoreTurnItem> for ThreadItem {
|
||||
result: image.result,
|
||||
saved_path: image.saved_path,
|
||||
},
|
||||
CoreTurnItem::FileChange(file_change) => ThreadItem::FileChange {
|
||||
id: file_change.id,
|
||||
changes: convert_patch_changes(&file_change.changes),
|
||||
status: file_change
|
||||
.status
|
||||
.as_ref()
|
||||
.map(PatchApplyStatus::from)
|
||||
.unwrap_or(PatchApplyStatus::InProgress),
|
||||
},
|
||||
CoreTurnItem::ContextCompaction(compaction) => {
|
||||
ThreadItem::ContextCompaction { id: compaction.id }
|
||||
}
|
||||
@@ -8053,6 +8088,7 @@ mod tests {
|
||||
use super::*;
|
||||
use codex_protocol::items::AgentMessageContent;
|
||||
use codex_protocol::items::AgentMessageItem;
|
||||
use codex_protocol::items::FileChangeItem;
|
||||
use codex_protocol::items::ReasoningItem;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::items::UserMessageItem;
|
||||
@@ -10333,6 +10369,35 @@ mod tests {
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
let file_change_item = TurnItem::FileChange(FileChangeItem {
|
||||
id: "patch-1".to_string(),
|
||||
changes: [(
|
||||
PathBuf::from("README.md"),
|
||||
codex_protocol::protocol::FileChange::Add {
|
||||
content: "hello\n".to_string(),
|
||||
},
|
||||
)]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
status: Some(codex_protocol::protocol::PatchApplyStatus::Completed),
|
||||
auto_approved: None,
|
||||
stdout: Some("Done!".to_string()),
|
||||
stderr: Some(String::new()),
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
ThreadItem::from(file_change_item),
|
||||
ThreadItem::FileChange {
|
||||
id: "patch-1".to_string(),
|
||||
changes: vec![FileUpdateChange {
|
||||
path: "README.md".to_string(),
|
||||
kind: PatchChangeKind::Add,
|
||||
diff: "hello\n".to_string(),
|
||||
}],
|
||||
status: PatchApplyStatus::Completed,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -10667,6 +10732,23 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_skill_read_params_serialization_uses_remote_plugin_id() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(PluginSkillReadParams {
|
||||
remote_marketplace_name: "chatgpt-global".to_string(),
|
||||
remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(),
|
||||
skill_name: "plan-work".to_string(),
|
||||
})
|
||||
.unwrap(),
|
||||
json!({
|
||||
"remoteMarketplaceName": "chatgpt-global",
|
||||
"remotePluginId": "plugins~Plugin_00000000000000000000000000000000",
|
||||
"skillName": "plan-work",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_share_params_and_response_serialization_use_camel_case_fields() {
|
||||
let plugin_path = if cfg!(windows) {
|
||||
@@ -10732,33 +10814,41 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_share_list_response_serializes_plugin_summaries() {
|
||||
fn plugin_share_list_response_serializes_share_items() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(PluginShareListResponse {
|
||||
data: vec![PluginSummary {
|
||||
id: "plugins~Plugin_00000000000000000000000000000000".to_string(),
|
||||
name: "gmail".to_string(),
|
||||
source: PluginSource::Remote,
|
||||
installed: false,
|
||||
enabled: false,
|
||||
install_policy: PluginInstallPolicy::Available,
|
||||
auth_policy: PluginAuthPolicy::OnUse,
|
||||
availability: PluginAvailability::Available,
|
||||
interface: None,
|
||||
data: vec![PluginShareListItem {
|
||||
plugin: PluginSummary {
|
||||
id: "plugins~Plugin_00000000000000000000000000000000".to_string(),
|
||||
name: "gmail".to_string(),
|
||||
source: PluginSource::Remote,
|
||||
installed: false,
|
||||
enabled: false,
|
||||
install_policy: PluginInstallPolicy::Available,
|
||||
auth_policy: PluginAuthPolicy::OnUse,
|
||||
availability: PluginAvailability::Available,
|
||||
interface: None,
|
||||
},
|
||||
share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(),
|
||||
local_plugin_path: None,
|
||||
}],
|
||||
})
|
||||
.unwrap(),
|
||||
json!({
|
||||
"data": [{
|
||||
"id": "plugins~Plugin_00000000000000000000000000000000",
|
||||
"name": "gmail",
|
||||
"source": { "type": "remote" },
|
||||
"installed": false,
|
||||
"enabled": false,
|
||||
"installPolicy": "AVAILABLE",
|
||||
"authPolicy": "ON_USE",
|
||||
"availability": "AVAILABLE",
|
||||
"interface": null,
|
||||
"plugin": {
|
||||
"id": "plugins~Plugin_00000000000000000000000000000000",
|
||||
"name": "gmail",
|
||||
"source": { "type": "remote" },
|
||||
"installed": false,
|
||||
"enabled": false,
|
||||
"installPolicy": "AVAILABLE",
|
||||
"authPolicy": "ON_USE",
|
||||
"availability": "AVAILABLE",
|
||||
"interface": null,
|
||||
},
|
||||
"shareUrl": "https://chatgpt.example/plugins/share/share-key-1",
|
||||
"localPluginPath": null,
|
||||
}],
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -203,6 +203,7 @@ Example with notification opt-out:
|
||||
- `marketplace/upgrade` — upgrade all configured Git plugin marketplaces, or one named marketplace when `marketplaceName` is provided. Returns selected marketplace names, upgraded roots, and per-marketplace errors.
|
||||
- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, plugin `availability` (`AVAILABLE` by default or `DISABLED_BY_ADMIN` for remote plugins blocked upstream), fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category (**under development; do not call from production clients yet**).
|
||||
- `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/apps/MCP server names. Returned plugin skills include their current `enabled` state after local config filtering. Plugin app summaries also include `needsAuth` when the server can determine connector accessibility (**under development; do not call from production clients yet**).
|
||||
- `plugin/skill/read` — read remote plugin skill markdown on demand by `remoteMarketplaceName`, `remotePluginId`, and `skillName`. This lets clients preview uninstalled remote plugin skills without downloading the plugin bundle.
|
||||
- `skills/changed` — notification emitted when watched local skill files change.
|
||||
- `app/list` — list available apps.
|
||||
- `device/key/create` — create or load a controller-local device signing key for an account/client binding. This local-key API is available only over local transports such as stdio and in-process; remote transports reject it. Hardware-backed providers are the target protection class; an OS-protected non-extractable fallback is allowed only with `protectionPolicy: "allow_os_protected_nonextractable"` and returns the reported `protectionClass`.
|
||||
|
||||
@@ -29,7 +29,6 @@ use codex_app_server_protocol::ExecPolicyAmendment as V2ExecPolicyAmendment;
|
||||
use codex_app_server_protocol::FileChangeApprovalDecision;
|
||||
use codex_app_server_protocol::FileChangeRequestApprovalParams;
|
||||
use codex_app_server_protocol::FileChangeRequestApprovalResponse;
|
||||
use codex_app_server_protocol::FileUpdateChange;
|
||||
use codex_app_server_protocol::GrantedPermissionProfile as V2GrantedPermissionProfile;
|
||||
use codex_app_server_protocol::GuardianWarningNotification;
|
||||
use codex_app_server_protocol::HookCompletedNotification;
|
||||
@@ -46,7 +45,6 @@ use codex_app_server_protocol::ModelVerificationNotification;
|
||||
use codex_app_server_protocol::NetworkApprovalContext as V2NetworkApprovalContext;
|
||||
use codex_app_server_protocol::NetworkPolicyAmendment as V2NetworkPolicyAmendment;
|
||||
use codex_app_server_protocol::NetworkPolicyRuleAction as V2NetworkPolicyRuleAction;
|
||||
use codex_app_server_protocol::PatchApplyStatus;
|
||||
use codex_app_server_protocol::PermissionsRequestApprovalParams;
|
||||
use codex_app_server_protocol::PermissionsRequestApprovalResponse;
|
||||
use codex_app_server_protocol::RawResponseItemCompletedNotification;
|
||||
@@ -82,11 +80,8 @@ use codex_app_server_protocol::TurnPlanUpdatedNotification;
|
||||
use codex_app_server_protocol::TurnStartedNotification;
|
||||
use codex_app_server_protocol::TurnStatus;
|
||||
use codex_app_server_protocol::WarningNotification;
|
||||
use codex_app_server_protocol::build_file_change_approval_request_item;
|
||||
use codex_app_server_protocol::build_file_change_end_item;
|
||||
use codex_app_server_protocol::build_item_from_guardian_event;
|
||||
use codex_app_server_protocol::build_turns_from_rollout_items;
|
||||
use codex_app_server_protocol::convert_patch_changes;
|
||||
use codex_app_server_protocol::guardian_auto_approval_review_notification;
|
||||
use codex_app_server_protocol::item_event_to_server_notification;
|
||||
use codex_core::CodexThread;
|
||||
@@ -524,28 +519,7 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
let permission_guard = thread_watch_manager
|
||||
.note_permission_requested(&conversation_id.to_string())
|
||||
.await;
|
||||
// Until we migrate the core to be aware of a first class FileChangeItem
|
||||
// and emit the corresponding EventMsg, we repurpose the call_id as the item_id.
|
||||
let item_id = event.call_id.clone();
|
||||
let patch_changes = convert_patch_changes(&event.changes);
|
||||
let first_start = {
|
||||
let mut state = thread_state.lock().await;
|
||||
state
|
||||
.turn_summary
|
||||
.file_change_started
|
||||
.insert(item_id.clone())
|
||||
};
|
||||
if first_start {
|
||||
let item = build_file_change_approval_request_item(&event);
|
||||
let notification = ItemStartedNotification {
|
||||
thread_id: conversation_id.to_string(),
|
||||
turn_id: event_turn_id.clone(),
|
||||
item,
|
||||
};
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::ItemStarted(notification))
|
||||
.await;
|
||||
}
|
||||
|
||||
let params = FileChangeRequestApprovalParams {
|
||||
thread_id: conversation_id.to_string(),
|
||||
@@ -559,14 +533,10 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
.await;
|
||||
tokio::spawn(async move {
|
||||
on_file_change_request_approval_response(
|
||||
event_turn_id,
|
||||
conversation_id,
|
||||
item_id,
|
||||
patch_changes,
|
||||
pending_request_id,
|
||||
rx,
|
||||
conversation,
|
||||
outgoing,
|
||||
thread_state.clone(),
|
||||
permission_guard,
|
||||
)
|
||||
@@ -1104,40 +1074,9 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
)
|
||||
.await;
|
||||
}
|
||||
EventMsg::PatchApplyBegin(patch_begin_event) => {
|
||||
// Until we migrate the core to be aware of a first class FileChangeItem
|
||||
// and emit the corresponding EventMsg, we repurpose the call_id as the item_id.
|
||||
let item_id = patch_begin_event.call_id.clone();
|
||||
|
||||
let first_start = {
|
||||
let mut state = thread_state.lock().await;
|
||||
state
|
||||
.turn_summary
|
||||
.file_change_started
|
||||
.insert(item_id.clone())
|
||||
};
|
||||
if first_start {
|
||||
let notification = item_event_to_server_notification(
|
||||
EventMsg::PatchApplyBegin(patch_begin_event),
|
||||
&conversation_id.to_string(),
|
||||
&event_turn_id,
|
||||
);
|
||||
outgoing.send_server_notification(notification).await;
|
||||
}
|
||||
}
|
||||
EventMsg::PatchApplyEnd(patch_end_event) => {
|
||||
// Until we migrate the core to be aware of a first class FileChangeItem
|
||||
// and emit the corresponding EventMsg, we repurpose the call_id as the item_id.
|
||||
let item_id = patch_end_event.call_id.clone();
|
||||
complete_file_change_item(
|
||||
conversation_id,
|
||||
item_id,
|
||||
build_file_change_end_item(&patch_end_event),
|
||||
event_turn_id.clone(),
|
||||
&outgoing,
|
||||
&thread_state,
|
||||
)
|
||||
.await;
|
||||
EventMsg::PatchApplyBegin(_) | EventMsg::PatchApplyEnd(_) => {
|
||||
// Core still fans out these deprecated events for legacy clients;
|
||||
// v2 clients receive the canonical FileChange item instead.
|
||||
}
|
||||
EventMsg::ExecCommandBegin(exec_command_begin_event) => {
|
||||
if matches!(
|
||||
@@ -1425,31 +1364,6 @@ async fn emit_turn_completed_with_status(
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn complete_file_change_item(
|
||||
conversation_id: ThreadId,
|
||||
item_id: String,
|
||||
item: ThreadItem,
|
||||
turn_id: String,
|
||||
outgoing: &ThreadScopedOutgoingMessageSender,
|
||||
thread_state: &Arc<Mutex<ThreadState>>,
|
||||
) {
|
||||
thread_state
|
||||
.lock()
|
||||
.await
|
||||
.turn_summary
|
||||
.file_change_started
|
||||
.remove(&item_id);
|
||||
|
||||
let notification = ItemCompletedNotification {
|
||||
thread_id: conversation_id.to_string(),
|
||||
turn_id,
|
||||
item,
|
||||
};
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::ItemCompleted(notification))
|
||||
.await;
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn start_command_execution_item(
|
||||
conversation_id: &ThreadId,
|
||||
@@ -2002,38 +1916,28 @@ fn render_review_output_text(output: &ReviewOutputEvent) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn map_file_change_approval_decision(
|
||||
decision: FileChangeApprovalDecision,
|
||||
) -> (ReviewDecision, Option<PatchApplyStatus>) {
|
||||
fn map_file_change_approval_decision(decision: FileChangeApprovalDecision) -> ReviewDecision {
|
||||
match decision {
|
||||
FileChangeApprovalDecision::Accept => (ReviewDecision::Approved, None),
|
||||
FileChangeApprovalDecision::AcceptForSession => (ReviewDecision::ApprovedForSession, None),
|
||||
FileChangeApprovalDecision::Decline => {
|
||||
(ReviewDecision::Denied, Some(PatchApplyStatus::Declined))
|
||||
}
|
||||
FileChangeApprovalDecision::Cancel => {
|
||||
(ReviewDecision::Abort, Some(PatchApplyStatus::Declined))
|
||||
}
|
||||
FileChangeApprovalDecision::Accept => ReviewDecision::Approved,
|
||||
FileChangeApprovalDecision::AcceptForSession => ReviewDecision::ApprovedForSession,
|
||||
FileChangeApprovalDecision::Decline => ReviewDecision::Denied,
|
||||
FileChangeApprovalDecision::Cancel => ReviewDecision::Abort,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn on_file_change_request_approval_response(
|
||||
event_turn_id: String,
|
||||
conversation_id: ThreadId,
|
||||
item_id: String,
|
||||
changes: Vec<FileUpdateChange>,
|
||||
pending_request_id: RequestId,
|
||||
receiver: oneshot::Receiver<ClientRequestResult>,
|
||||
codex: Arc<CodexThread>,
|
||||
outgoing: ThreadScopedOutgoingMessageSender,
|
||||
thread_state: Arc<Mutex<ThreadState>>,
|
||||
permission_guard: ThreadWatchActiveGuard,
|
||||
) {
|
||||
let response = receiver.await;
|
||||
resolve_server_request_on_thread_listener(&thread_state, pending_request_id).await;
|
||||
drop(permission_guard);
|
||||
let (decision, completion_status) = match response {
|
||||
let decision = match response {
|
||||
Ok(Ok(value)) => {
|
||||
let response = serde_json::from_value::<FileChangeRequestApprovalResponse>(value)
|
||||
.unwrap_or_else(|err| {
|
||||
@@ -2043,39 +1947,19 @@ async fn on_file_change_request_approval_response(
|
||||
}
|
||||
});
|
||||
|
||||
let (decision, completion_status) =
|
||||
map_file_change_approval_decision(response.decision);
|
||||
// Allow EventMsg::PatchApplyEnd to emit ItemCompleted for accepted patches.
|
||||
// Only short-circuit on declines/cancels/failures.
|
||||
(decision, completion_status)
|
||||
map_file_change_approval_decision(response.decision)
|
||||
}
|
||||
Ok(Err(err)) if is_turn_transition_server_request_error(&err) => return,
|
||||
Ok(Err(err)) => {
|
||||
error!("request failed with client error: {err:?}");
|
||||
(ReviewDecision::Denied, Some(PatchApplyStatus::Failed))
|
||||
ReviewDecision::Denied
|
||||
}
|
||||
Err(err) => {
|
||||
error!("request failed: {err:?}");
|
||||
(ReviewDecision::Denied, Some(PatchApplyStatus::Failed))
|
||||
ReviewDecision::Denied
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(status) = completion_status {
|
||||
complete_file_change_item(
|
||||
conversation_id,
|
||||
item_id.clone(),
|
||||
ThreadItem::FileChange {
|
||||
id: item_id.clone(),
|
||||
changes,
|
||||
status,
|
||||
},
|
||||
event_turn_id.clone(),
|
||||
&outgoing,
|
||||
&thread_state,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
if let Err(err) = codex
|
||||
.submit(Op::PatchApproval {
|
||||
id: item_id,
|
||||
@@ -2687,12 +2571,7 @@ mod tests {
|
||||
thread_id: conversation_id,
|
||||
thread: conversation,
|
||||
..
|
||||
} = thread_manager
|
||||
.start_thread(
|
||||
config.clone(),
|
||||
codex_core::thread_store_from_config(&config),
|
||||
)
|
||||
.await?;
|
||||
} = thread_manager.start_thread(config.clone()).await?;
|
||||
let thread_state = new_thread_state();
|
||||
let thread_watch_manager = ThreadWatchManager::new();
|
||||
let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY);
|
||||
@@ -2891,10 +2770,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn file_change_accept_for_session_maps_to_approved_for_session() {
|
||||
let (decision, completion_status) =
|
||||
let decision =
|
||||
map_file_change_approval_decision(FileChangeApprovalDecision::AcceptForSession);
|
||||
assert_eq!(decision, ReviewDecision::ApprovedForSession);
|
||||
assert_eq!(completion_status, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -122,10 +122,13 @@ use codex_app_server_protocol::PluginReadParams;
|
||||
use codex_app_server_protocol::PluginReadResponse;
|
||||
use codex_app_server_protocol::PluginShareDeleteParams;
|
||||
use codex_app_server_protocol::PluginShareDeleteResponse;
|
||||
use codex_app_server_protocol::PluginShareListItem;
|
||||
use codex_app_server_protocol::PluginShareListParams;
|
||||
use codex_app_server_protocol::PluginShareListResponse;
|
||||
use codex_app_server_protocol::PluginShareSaveParams;
|
||||
use codex_app_server_protocol::PluginShareSaveResponse;
|
||||
use codex_app_server_protocol::PluginSkillReadParams;
|
||||
use codex_app_server_protocol::PluginSkillReadResponse;
|
||||
use codex_app_server_protocol::PluginSource;
|
||||
use codex_app_server_protocol::PluginSummary;
|
||||
use codex_app_server_protocol::PluginUninstallParams;
|
||||
@@ -259,7 +262,6 @@ use codex_core::ThreadManager;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config::NetworkProxyAuditMetadata;
|
||||
use codex_core::config::ThreadStoreConfig;
|
||||
use codex_core::config::edit::ConfigEdit;
|
||||
use codex_core::config::edit::ConfigEditsBuilder;
|
||||
use codex_core::exec::ExecCapturePolicy;
|
||||
@@ -298,6 +300,7 @@ use codex_core_plugins::remote::RemoteMarketplace;
|
||||
use codex_core_plugins::remote::RemotePluginCatalogError;
|
||||
use codex_core_plugins::remote::RemotePluginDetail as RemoteCatalogPluginDetail;
|
||||
use codex_core_plugins::remote::RemotePluginServiceConfig;
|
||||
use codex_core_plugins::remote::RemotePluginShareSummary as RemoteCatalogPluginShareSummary;
|
||||
use codex_core_plugins::remote::RemotePluginSummary as RemoteCatalogPluginSummary;
|
||||
use codex_exec_server::EnvironmentManager;
|
||||
use codex_exec_server::LOCAL_FS;
|
||||
@@ -378,12 +381,10 @@ use codex_state::ThreadMetadata;
|
||||
use codex_state::ThreadMetadataBuilder;
|
||||
use codex_state::log_db::LogDbLayer;
|
||||
use codex_thread_store::ArchiveThreadParams as StoreArchiveThreadParams;
|
||||
use codex_thread_store::InMemoryThreadStore;
|
||||
use codex_thread_store::ListThreadsParams as StoreListThreadsParams;
|
||||
use codex_thread_store::LocalThreadStore;
|
||||
use codex_thread_store::ReadThreadByRolloutPathParams as StoreReadThreadByRolloutPathParams;
|
||||
use codex_thread_store::ReadThreadParams as StoreReadThreadParams;
|
||||
use codex_thread_store::RemoteThreadStore;
|
||||
use codex_thread_store::SortDirection as StoreSortDirection;
|
||||
use codex_thread_store::StoredThread;
|
||||
use codex_thread_store::ThreadMetadataPatch as StoreThreadMetadataPatch;
|
||||
@@ -691,18 +692,11 @@ pub(crate) struct CodexMessageProcessorArgs {
|
||||
/// go through `config_manager`.
|
||||
pub(crate) config: Arc<Config>,
|
||||
pub(crate) config_manager: ConfigManager,
|
||||
pub(crate) thread_store: Arc<dyn ThreadStore>,
|
||||
pub(crate) feedback: CodexFeedback,
|
||||
pub(crate) log_db: Option<LogDbLayer>,
|
||||
}
|
||||
|
||||
fn thread_store_from_config(config: &Config) -> Arc<dyn ThreadStore> {
|
||||
match &config.experimental_thread_store {
|
||||
ThreadStoreConfig::Local => Arc::new(configured_local_thread_store(config)),
|
||||
ThreadStoreConfig::Remote { endpoint } => Arc::new(RemoteThreadStore::new(endpoint)),
|
||||
ThreadStoreConfig::InMemory { id } => InMemoryThreadStore::for_id(id),
|
||||
}
|
||||
}
|
||||
|
||||
fn environment_selection_error_message(err: CodexErr) -> String {
|
||||
match err {
|
||||
CodexErr::InvalidRequest(message) => message,
|
||||
@@ -710,10 +704,6 @@ fn environment_selection_error_message(err: CodexErr) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn configured_local_thread_store(config: &Config) -> LocalThreadStore {
|
||||
LocalThreadStore::new(codex_rollout::RolloutConfig::from_view(config))
|
||||
}
|
||||
|
||||
impl CodexMessageProcessor {
|
||||
async fn instruction_sources_from_config(config: &Config) -> Vec<AbsolutePathBuf> {
|
||||
codex_core::AgentsMdManager::new(config)
|
||||
@@ -776,6 +766,39 @@ impl CodexMessageProcessor {
|
||||
self.thread_manager.skills_manager().clear_cache();
|
||||
}
|
||||
|
||||
async fn maybe_refresh_remote_installed_plugins_cache_for_current_config(
|
||||
config_manager: &ConfigManager,
|
||||
thread_manager: &Arc<ThreadManager>,
|
||||
auth: Option<CodexAuth>,
|
||||
) {
|
||||
match config_manager
|
||||
.load_latest_config(/*fallback_cwd*/ None)
|
||||
.await
|
||||
{
|
||||
Ok(config) => {
|
||||
let refresh_thread_manager = Arc::clone(thread_manager);
|
||||
let refresh_config = config.clone();
|
||||
thread_manager
|
||||
.plugins_manager()
|
||||
.maybe_start_remote_installed_plugins_cache_refresh(
|
||||
&config.plugins_config_input(),
|
||||
auth,
|
||||
Some(Arc::new(move || {
|
||||
Self::spawn_effective_plugins_changed_task(
|
||||
Arc::clone(&refresh_thread_manager),
|
||||
refresh_config.clone(),
|
||||
);
|
||||
})),
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"failed to reload config after account changed, skipping remote installed plugins cache refresh: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn current_account_updated_notification(&self) -> AccountUpdatedNotification {
|
||||
let auth = self.auth_manager.auth_cached();
|
||||
AccountUpdatedNotification {
|
||||
@@ -830,6 +853,7 @@ impl CodexMessageProcessor {
|
||||
arg0_paths,
|
||||
config,
|
||||
config_manager,
|
||||
thread_store,
|
||||
feedback,
|
||||
log_db,
|
||||
} = args;
|
||||
@@ -839,7 +863,7 @@ impl CodexMessageProcessor {
|
||||
outgoing: outgoing.clone(),
|
||||
analytics_events_client,
|
||||
arg0_paths,
|
||||
thread_store: thread_store_from_config(&config),
|
||||
thread_store,
|
||||
config,
|
||||
config_manager,
|
||||
active_login: Arc::new(Mutex::new(None)),
|
||||
@@ -1127,6 +1151,10 @@ impl CodexMessageProcessor {
|
||||
self.plugin_read(to_connection_request_id(request_id), params)
|
||||
.await;
|
||||
}
|
||||
ClientRequest::PluginSkillRead { request_id, params } => {
|
||||
self.plugin_skill_read(to_connection_request_id(request_id), params)
|
||||
.await;
|
||||
}
|
||||
ClientRequest::PluginShareSave { request_id, params } => {
|
||||
self.plugin_share_save(to_connection_request_id(request_id), params)
|
||||
.await;
|
||||
@@ -1566,10 +1594,10 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
|
||||
let outgoing_clone = self.outgoing.clone();
|
||||
let active_login = self.active_login.clone();
|
||||
let auth_manager = self.auth_manager.clone();
|
||||
let config_manager = self.config_manager.clone();
|
||||
let thread_manager = Arc::clone(&self.thread_manager);
|
||||
let chatgpt_base_url = self.config.chatgpt_base_url.clone();
|
||||
let active_login = self.active_login.clone();
|
||||
let auth_url = server.auth_url.clone();
|
||||
tokio::spawn(async move {
|
||||
let (success, error_msg) = match tokio::time::timeout(
|
||||
@@ -1588,8 +1616,8 @@ impl CodexMessageProcessor {
|
||||
|
||||
Self::send_chatgpt_login_completion_notifications(
|
||||
&outgoing_clone,
|
||||
auth_manager,
|
||||
config_manager,
|
||||
thread_manager,
|
||||
chatgpt_base_url,
|
||||
login_id,
|
||||
success,
|
||||
@@ -1642,10 +1670,10 @@ impl CodexMessageProcessor {
|
||||
let user_code = device_code.user_code.clone();
|
||||
|
||||
let outgoing_clone = self.outgoing.clone();
|
||||
let active_login = self.active_login.clone();
|
||||
let auth_manager = self.auth_manager.clone();
|
||||
let config_manager = self.config_manager.clone();
|
||||
let thread_manager = Arc::clone(&self.thread_manager);
|
||||
let chatgpt_base_url = self.config.chatgpt_base_url.clone();
|
||||
let active_login = self.active_login.clone();
|
||||
tokio::spawn(async move {
|
||||
let (success, error_msg) = tokio::select! {
|
||||
_ = cancel.cancelled() => {
|
||||
@@ -1661,8 +1689,8 @@ impl CodexMessageProcessor {
|
||||
|
||||
Self::send_chatgpt_login_completion_notifications(
|
||||
&outgoing_clone,
|
||||
auth_manager,
|
||||
config_manager,
|
||||
thread_manager,
|
||||
chatgpt_base_url,
|
||||
login_id,
|
||||
success,
|
||||
@@ -1791,6 +1819,13 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
|
||||
async fn send_login_success_notifications(&self, login_id: Option<Uuid>) {
|
||||
Self::maybe_refresh_remote_installed_plugins_cache_for_current_config(
|
||||
&self.config_manager,
|
||||
&self.thread_manager,
|
||||
self.auth_manager.auth_cached(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let payload_login_completed = AccountLoginCompletedNotification {
|
||||
login_id: login_id.map(|id| id.to_string()),
|
||||
success: true,
|
||||
@@ -1811,8 +1846,8 @@ impl CodexMessageProcessor {
|
||||
|
||||
async fn send_chatgpt_login_completion_notifications(
|
||||
outgoing: &OutgoingMessageSender,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
config_manager: ConfigManager,
|
||||
thread_manager: Arc<ThreadManager>,
|
||||
chatgpt_base_url: String,
|
||||
login_id: Uuid,
|
||||
success: bool,
|
||||
@@ -1828,6 +1863,7 @@ impl CodexMessageProcessor {
|
||||
.await;
|
||||
|
||||
if success {
|
||||
let auth_manager = thread_manager.auth_manager();
|
||||
auth_manager.reload().await;
|
||||
config_manager
|
||||
.replace_cloud_requirements_loader(auth_manager.clone(), chatgpt_base_url);
|
||||
@@ -1836,6 +1872,12 @@ impl CodexMessageProcessor {
|
||||
.await;
|
||||
|
||||
let auth = auth_manager.auth_cached();
|
||||
Self::maybe_refresh_remote_installed_plugins_cache_for_current_config(
|
||||
&config_manager,
|
||||
&thread_manager,
|
||||
auth.clone(),
|
||||
)
|
||||
.await;
|
||||
let payload_v2 = AccountUpdatedNotification {
|
||||
auth_mode: auth.as_ref().map(CodexAuth::api_auth_mode),
|
||||
plan_type: auth.as_ref().and_then(CodexAuth::account_plan_type),
|
||||
@@ -1866,6 +1908,13 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
Self::maybe_refresh_remote_installed_plugins_cache_for_current_config(
|
||||
&self.config_manager,
|
||||
&self.thread_manager,
|
||||
self.auth_manager.auth_cached(),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Reflect the current auth method after logout (likely None).
|
||||
Ok(self
|
||||
.auth_manager
|
||||
@@ -2586,7 +2635,6 @@ impl CodexMessageProcessor {
|
||||
let imported_thread = self
|
||||
.thread_manager
|
||||
.start_thread_with_options(StartThreadOptions {
|
||||
thread_store: thread_store_from_config(&config),
|
||||
config,
|
||||
initial_history: InitialHistory::Forked(rollout_items),
|
||||
session_source: None,
|
||||
@@ -2784,7 +2832,6 @@ impl CodexMessageProcessor {
|
||||
} = listener_task_context
|
||||
.thread_manager
|
||||
.start_thread_with_options(StartThreadOptions {
|
||||
thread_store: thread_store_from_config(&config),
|
||||
config,
|
||||
initial_history: match session_start_source
|
||||
.unwrap_or(codex_app_server_protocol::ThreadStartSource::Startup)
|
||||
@@ -4348,7 +4395,6 @@ impl CodexMessageProcessor {
|
||||
.thread_manager
|
||||
.resume_thread_with_history(
|
||||
config.clone(),
|
||||
thread_store_from_config(&config),
|
||||
thread_history,
|
||||
self.auth_manager.clone(),
|
||||
persist_extended_history,
|
||||
@@ -4503,27 +4549,20 @@ impl CodexMessageProcessor {
|
||||
request_id: &ConnectionRequestId,
|
||||
params: &ThreadResumeParams,
|
||||
) -> Result<bool, JSONRPCErrorError> {
|
||||
if let Ok(existing_thread_id) = ThreadId::from_string(¶ms.thread_id)
|
||||
&& let Ok(existing_thread) = self.thread_manager.get_thread(existing_thread_id).await
|
||||
{
|
||||
if params.history.is_some() {
|
||||
let running_thread = if params.history.is_some() {
|
||||
if let Ok(existing_thread_id) = ThreadId::from_string(¶ms.thread_id)
|
||||
&& self
|
||||
.thread_manager
|
||||
.get_thread(existing_thread_id)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
return Err(invalid_request(format!(
|
||||
"cannot resume thread {existing_thread_id} with history while it is already running"
|
||||
)));
|
||||
}
|
||||
|
||||
if let (Some(requested_path), Some(active_path)) = (
|
||||
params.path.as_ref(),
|
||||
existing_thread.rollout_path().as_ref(),
|
||||
) && requested_path != active_path
|
||||
{
|
||||
return Err(invalid_request(format!(
|
||||
"cannot resume running thread {existing_thread_id} with mismatched path: requested `{}`, active `{}`",
|
||||
requested_path.display(),
|
||||
active_path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
None
|
||||
} else if params.path.is_some() {
|
||||
let source_thread = self
|
||||
.read_stored_thread_for_resume(
|
||||
¶ms.thread_id,
|
||||
@@ -4531,12 +4570,45 @@ impl CodexMessageProcessor {
|
||||
/*include_history*/ true,
|
||||
)
|
||||
.await?;
|
||||
let existing_thread_id = source_thread.thread_id;
|
||||
if let Ok(existing_thread) = self.thread_manager.get_thread(existing_thread_id).await {
|
||||
if let (Some(requested_path), Some(active_path)) = (
|
||||
params.path.as_ref(),
|
||||
existing_thread.rollout_path().as_ref(),
|
||||
) && requested_path != active_path
|
||||
{
|
||||
return Err(invalid_request(format!(
|
||||
"cannot resume running thread {existing_thread_id} with stale path: requested `{}`, active `{}`",
|
||||
requested_path.display(),
|
||||
active_path.display()
|
||||
)));
|
||||
}
|
||||
Some((existing_thread_id, existing_thread, source_thread))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else if let Ok(existing_thread_id) = ThreadId::from_string(¶ms.thread_id)
|
||||
&& let Ok(existing_thread) = self.thread_manager.get_thread(existing_thread_id).await
|
||||
{
|
||||
let source_thread = self
|
||||
.read_stored_thread_for_resume(
|
||||
¶ms.thread_id,
|
||||
/*path*/ None,
|
||||
/*include_history*/ true,
|
||||
)
|
||||
.await?;
|
||||
if source_thread.thread_id != existing_thread_id {
|
||||
return Err(invalid_request(format!(
|
||||
"cannot resume running thread {existing_thread_id} from source thread {}",
|
||||
source_thread.thread_id
|
||||
)));
|
||||
}
|
||||
Some((existing_thread_id, existing_thread, source_thread))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some((existing_thread_id, existing_thread, source_thread)) = running_thread {
|
||||
let history_items = source_thread
|
||||
.history
|
||||
.as_ref()
|
||||
@@ -4731,11 +4803,10 @@ impl CodexMessageProcessor {
|
||||
|
||||
async fn read_stored_thread_for_new_fork(
|
||||
&self,
|
||||
thread_store: &dyn ThreadStore,
|
||||
thread_id: ThreadId,
|
||||
include_history: bool,
|
||||
) -> Result<StoredThread, JSONRPCErrorError> {
|
||||
thread_store
|
||||
self.thread_store
|
||||
.read_thread(StoreReadThreadParams {
|
||||
thread_id,
|
||||
include_archived: true,
|
||||
@@ -4938,7 +5009,6 @@ impl CodexMessageProcessor {
|
||||
|
||||
let fallback_model_provider = config.model_provider_id.clone();
|
||||
let instruction_sources = Self::instruction_sources_from_config(&config).await;
|
||||
let fork_thread_store = thread_store_from_config(&config);
|
||||
|
||||
let NewThread {
|
||||
thread_id,
|
||||
@@ -4950,7 +5020,6 @@ impl CodexMessageProcessor {
|
||||
.fork_thread_from_history(
|
||||
ForkSnapshot::Interrupted,
|
||||
config,
|
||||
fork_thread_store.clone(),
|
||||
InitialHistory::Resumed(ResumedHistory {
|
||||
conversation_id: source_thread_id,
|
||||
history: history_items.clone(),
|
||||
@@ -4986,11 +5055,7 @@ impl CodexMessageProcessor {
|
||||
let mut thread =
|
||||
if let Some(fork_rollout_path) = session_configured.rollout_path.as_ref() {
|
||||
let stored_thread = self
|
||||
.read_stored_thread_for_new_fork(
|
||||
fork_thread_store.as_ref(),
|
||||
thread_id,
|
||||
include_turns,
|
||||
)
|
||||
.read_stored_thread_for_new_fork(thread_id, include_turns)
|
||||
.await?;
|
||||
self.stored_thread_to_api_thread(
|
||||
stored_thread,
|
||||
@@ -7250,7 +7315,6 @@ impl CodexMessageProcessor {
|
||||
.fork_thread(
|
||||
ForkSnapshot::Interrupted,
|
||||
config.clone(),
|
||||
thread_store_from_config(&config),
|
||||
rollout_path,
|
||||
/*persist_extended_history*/ false,
|
||||
self.request_trace_context(request_id).await,
|
||||
|
||||
@@ -3,6 +3,8 @@ use crate::error_code::internal_error;
|
||||
use crate::error_code::invalid_request;
|
||||
use codex_app_server_protocol::PluginAvailability;
|
||||
use codex_app_server_protocol::PluginInstallPolicy;
|
||||
use codex_core_plugins::remote::is_valid_remote_plugin_id;
|
||||
use codex_core_plugins::remote::validate_remote_plugin_id;
|
||||
|
||||
impl CodexMessageProcessor {
|
||||
pub(super) async fn plugin_list(
|
||||
@@ -261,15 +263,15 @@ impl CodexMessageProcessor {
|
||||
if !config.features.enabled(Feature::Plugins)
|
||||
|| !config.features.enabled(Feature::RemotePlugin)
|
||||
{
|
||||
return Err(invalid_request("remote plugin read is not enabled"));
|
||||
return Err(invalid_request(format!(
|
||||
"remote plugin read is not enabled for marketplace {remote_marketplace_name}"
|
||||
)));
|
||||
}
|
||||
let auth = self.auth_manager.auth().await;
|
||||
let remote_plugin_service_config = RemotePluginServiceConfig {
|
||||
chatgpt_base_url: config.chatgpt_base_url.clone(),
|
||||
};
|
||||
if plugin_name.is_empty() || !is_valid_remote_plugin_id(&plugin_name) {
|
||||
return Err(invalid_request("invalid remote plugin id"));
|
||||
}
|
||||
validate_remote_plugin_id(&plugin_name)?;
|
||||
let remote_detail = codex_core_plugins::remote::fetch_remote_plugin_detail(
|
||||
&remote_plugin_service_config,
|
||||
auth.as_ref(),
|
||||
@@ -300,6 +302,61 @@ impl CodexMessageProcessor {
|
||||
Ok(PluginReadResponse { plugin })
|
||||
}
|
||||
|
||||
pub(super) async fn plugin_skill_read(
|
||||
&self,
|
||||
request_id: ConnectionRequestId,
|
||||
params: PluginSkillReadParams,
|
||||
) {
|
||||
let result = self.plugin_skill_read_response(params).await;
|
||||
self.outgoing.send_result(request_id, result).await;
|
||||
}
|
||||
|
||||
async fn plugin_skill_read_response(
|
||||
&self,
|
||||
params: PluginSkillReadParams,
|
||||
) -> Result<PluginSkillReadResponse, JSONRPCErrorError> {
|
||||
let PluginSkillReadParams {
|
||||
remote_marketplace_name,
|
||||
remote_plugin_id,
|
||||
skill_name,
|
||||
} = params;
|
||||
|
||||
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
|
||||
if !config.features.enabled(Feature::Plugins)
|
||||
|| !config.features.enabled(Feature::RemotePlugin)
|
||||
{
|
||||
return Err(invalid_request(format!(
|
||||
"remote plugin skill read is not enabled for marketplace {remote_marketplace_name}"
|
||||
)));
|
||||
}
|
||||
validate_remote_plugin_id(&remote_plugin_id)?;
|
||||
if skill_name.is_empty() {
|
||||
return Err(invalid_request(
|
||||
"invalid remote plugin skill name: cannot be empty",
|
||||
));
|
||||
}
|
||||
|
||||
let auth = self.auth_manager.auth().await;
|
||||
let remote_plugin_service_config = RemotePluginServiceConfig {
|
||||
chatgpt_base_url: config.chatgpt_base_url.clone(),
|
||||
};
|
||||
let remote_skill_detail = codex_core_plugins::remote::fetch_remote_plugin_skill_detail(
|
||||
&remote_plugin_service_config,
|
||||
auth.as_ref(),
|
||||
&remote_marketplace_name,
|
||||
&remote_plugin_id,
|
||||
&skill_name,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
remote_plugin_catalog_error_to_jsonrpc(err, "read remote plugin skill details")
|
||||
})?;
|
||||
|
||||
Ok(PluginSkillReadResponse {
|
||||
contents: remote_skill_detail.contents,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn plugin_share_save(
|
||||
&self,
|
||||
request_id: ConnectionRequestId,
|
||||
@@ -330,14 +387,16 @@ impl CodexMessageProcessor {
|
||||
let result = codex_core_plugins::remote::save_remote_plugin_share(
|
||||
&remote_plugin_service_config,
|
||||
auth.as_ref(),
|
||||
plugin_path.as_path(),
|
||||
config.codex_home.as_path(),
|
||||
&plugin_path,
|
||||
remote_plugin_id.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "save remote plugin share"))?;
|
||||
let remote_plugin_id = result.remote_plugin_id;
|
||||
self.clear_plugin_related_caches();
|
||||
Ok(PluginShareSaveResponse {
|
||||
remote_plugin_id: result.remote_plugin_id,
|
||||
remote_plugin_id,
|
||||
share_url: result.share_url.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
@@ -361,11 +420,24 @@ impl CodexMessageProcessor {
|
||||
let data = codex_core_plugins::remote::list_remote_plugin_shares(
|
||||
&remote_plugin_service_config,
|
||||
auth.as_ref(),
|
||||
config.codex_home.as_path(),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "list remote plugin shares"))?
|
||||
.into_iter()
|
||||
.map(remote_plugin_summary_to_info)
|
||||
.map(|summary| {
|
||||
let RemoteCatalogPluginShareSummary {
|
||||
summary,
|
||||
share_url,
|
||||
local_plugin_path,
|
||||
} = summary;
|
||||
let plugin = remote_plugin_summary_to_info(summary);
|
||||
PluginShareListItem {
|
||||
plugin,
|
||||
share_url: share_url.unwrap_or_default(),
|
||||
local_plugin_path,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(PluginShareListResponse { data })
|
||||
}
|
||||
@@ -395,6 +467,7 @@ impl CodexMessageProcessor {
|
||||
codex_core_plugins::remote::delete_remote_plugin_share(
|
||||
&remote_plugin_service_config,
|
||||
auth.as_ref(),
|
||||
config.codex_home.as_path(),
|
||||
&remote_plugin_id,
|
||||
)
|
||||
.await
|
||||
@@ -514,13 +587,11 @@ impl CodexMessageProcessor {
|
||||
if !config.features.enabled(Feature::Plugins)
|
||||
|| !config.features.enabled(Feature::RemotePlugin)
|
||||
{
|
||||
return Err(invalid_request("remote plugin install is not enabled"));
|
||||
}
|
||||
if remote_plugin_id.is_empty() || !is_valid_remote_plugin_id(&remote_plugin_id) {
|
||||
return Err(invalid_request(
|
||||
"invalid remote plugin id: only ASCII letters, digits, `_`, `-`, and `~` are allowed",
|
||||
));
|
||||
return Err(invalid_request(format!(
|
||||
"remote plugin install is not enabled for marketplace {remote_marketplace_name}"
|
||||
)));
|
||||
}
|
||||
validate_remote_plugin_id(&remote_plugin_id)?;
|
||||
|
||||
let auth = self.auth_manager.auth().await;
|
||||
let remote_plugin_service_config = RemotePluginServiceConfig {
|
||||
@@ -703,13 +774,13 @@ impl CodexMessageProcessor {
|
||||
) -> Result<PluginUninstallResponse, JSONRPCErrorError> {
|
||||
let PluginUninstallParams { plugin_id } = params;
|
||||
if codex_plugin::PluginId::parse(&plugin_id).is_err()
|
||||
&& (plugin_id.is_empty() || !is_valid_remote_plugin_id(&plugin_id))
|
||||
&& !is_valid_remote_uninstall_plugin_id(&plugin_id)
|
||||
{
|
||||
return Err(invalid_request(
|
||||
"invalid plugin id: expected a local plugin id or remote plugin id",
|
||||
"invalid plugin id: expected a local plugin id in the form `plugin@marketplace` or a remote plugin id starting with `plugins~`, `plugins_`, `app_`, `asdk_app_`, or `connector_`",
|
||||
));
|
||||
}
|
||||
if !plugin_id.is_empty() && is_valid_remote_plugin_id(&plugin_id) {
|
||||
if is_valid_remote_uninstall_plugin_id(&plugin_id) {
|
||||
return self.remote_plugin_uninstall_response(plugin_id).await;
|
||||
}
|
||||
let plugins_manager = self.thread_manager.plugins_manager();
|
||||
@@ -800,9 +871,7 @@ impl CodexMessageProcessor {
|
||||
{
|
||||
return Err(invalid_request("remote plugin uninstall is not enabled"));
|
||||
}
|
||||
if plugin_id.is_empty() || !is_valid_remote_plugin_id(&plugin_id) {
|
||||
return Err(invalid_request("invalid remote plugin id"));
|
||||
}
|
||||
validate_remote_plugin_id(&plugin_id)?;
|
||||
|
||||
let auth = self.auth_manager.auth().await;
|
||||
let remote_plugin_service_config = RemotePluginServiceConfig {
|
||||
@@ -838,10 +907,13 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_valid_remote_plugin_id(plugin_name: &str) -> bool {
|
||||
plugin_name
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '~')
|
||||
fn is_valid_remote_uninstall_plugin_id(plugin_name: &str) -> bool {
|
||||
is_valid_remote_plugin_id(plugin_name)
|
||||
&& (plugin_name.starts_with("plugins~")
|
||||
|| plugin_name.starts_with("plugins_")
|
||||
|| plugin_name.starts_with("app_")
|
||||
|| plugin_name.starts_with("asdk_app_")
|
||||
|| plugin_name.starts_with("connector_"))
|
||||
}
|
||||
|
||||
fn remote_marketplace_to_info(marketplace: RemoteMarketplace) -> PluginMarketplaceEntry {
|
||||
@@ -919,7 +991,8 @@ fn remote_plugin_catalog_error_to_jsonrpc(
|
||||
}
|
||||
}
|
||||
RemotePluginCatalogError::InvalidPluginPath { .. }
|
||||
| RemotePluginCatalogError::ArchiveTooLarge { .. } => JSONRPCErrorError {
|
||||
| RemotePluginCatalogError::ArchiveTooLarge { .. }
|
||||
| RemotePluginCatalogError::UnknownMarketplace { .. } => JSONRPCErrorError {
|
||||
code: INVALID_REQUEST_ERROR_CODE,
|
||||
message: format!("{context}: {err}"),
|
||||
data: None,
|
||||
@@ -928,7 +1001,10 @@ fn remote_plugin_catalog_error_to_jsonrpc(
|
||||
| RemotePluginCatalogError::Request { .. }
|
||||
| RemotePluginCatalogError::UnexpectedStatus { .. }
|
||||
| RemotePluginCatalogError::Decode { .. }
|
||||
| RemotePluginCatalogError::InvalidBaseUrl(_)
|
||||
| RemotePluginCatalogError::InvalidBaseUrlPath
|
||||
| RemotePluginCatalogError::UnexpectedPluginId { .. }
|
||||
| RemotePluginCatalogError::UnexpectedSkillName { .. }
|
||||
| RemotePluginCatalogError::UnexpectedEnabledState { .. }
|
||||
| RemotePluginCatalogError::Archive { .. }
|
||||
| RemotePluginCatalogError::ArchiveJoin(_)
|
||||
|
||||
@@ -65,6 +65,7 @@ use codex_arg0::Arg0DispatchPaths;
|
||||
use codex_chatgpt::connectors;
|
||||
use codex_core::ThreadManager;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::thread_store_from_config;
|
||||
use codex_exec_server::EnvironmentManager;
|
||||
use codex_features::Feature;
|
||||
use codex_feedback::CodexFeedback;
|
||||
@@ -285,12 +286,17 @@ impl MessageProcessor {
|
||||
auth_manager.set_external_auth(Arc::new(ExternalAuthRefreshBridge {
|
||||
outgoing: outgoing.clone(),
|
||||
}));
|
||||
// The thread store is intentionally process-scoped. Config reloads can
|
||||
// affect per-thread behavior, but they must not move newly started,
|
||||
// resumed, or forked threads to a different persistence backend/root.
|
||||
let thread_store = thread_store_from_config(config.as_ref());
|
||||
let thread_manager = Arc::new(ThreadManager::new(
|
||||
config.as_ref(),
|
||||
auth_manager.clone(),
|
||||
session_source,
|
||||
environment_manager,
|
||||
Some(analytics_events_client.clone()),
|
||||
Arc::clone(&thread_store),
|
||||
));
|
||||
thread_manager
|
||||
.plugins_manager()
|
||||
@@ -304,6 +310,7 @@ impl MessageProcessor {
|
||||
arg0_paths,
|
||||
config: Arc::clone(&config),
|
||||
config_manager: config_manager.clone(),
|
||||
thread_store,
|
||||
feedback,
|
||||
log_db,
|
||||
});
|
||||
|
||||
@@ -61,7 +61,6 @@ pub(crate) enum ThreadListenerCommand {
|
||||
#[derive(Default, Clone)]
|
||||
pub(crate) struct TurnSummary {
|
||||
pub(crate) started_at: Option<i64>,
|
||||
pub(crate) file_change_started: HashSet<String>,
|
||||
pub(crate) command_execution_started: HashSet<String>,
|
||||
pub(crate) last_error: Option<TurnError>,
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ use codex_app_server_protocol::ModelProviderCapabilitiesReadParams;
|
||||
use codex_app_server_protocol::PluginInstallParams;
|
||||
use codex_app_server_protocol::PluginListParams;
|
||||
use codex_app_server_protocol::PluginReadParams;
|
||||
use codex_app_server_protocol::PluginSkillReadParams;
|
||||
use codex_app_server_protocol::PluginUninstallParams;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ReviewStartParams;
|
||||
@@ -660,6 +661,15 @@ impl McpProcess {
|
||||
self.send_request("plugin/read", params).await
|
||||
}
|
||||
|
||||
/// Send a `plugin/skill/read` JSON-RPC request.
|
||||
pub async fn send_plugin_skill_read_request(
|
||||
&mut self,
|
||||
params: PluginSkillReadParams,
|
||||
) -> anyhow::Result<i64> {
|
||||
let params = Some(serde_json::to_value(params)?);
|
||||
self.send_request("plugin/skill/read", params).await
|
||||
}
|
||||
|
||||
/// Send an `mcpServerStatus/list` JSON-RPC request.
|
||||
pub async fn send_list_mcp_server_status_request(
|
||||
&mut self,
|
||||
|
||||
@@ -22,6 +22,8 @@ use codex_app_server_protocol::PluginAuthPolicy;
|
||||
use codex_app_server_protocol::PluginInstallPolicy;
|
||||
use codex_app_server_protocol::PluginReadParams;
|
||||
use codex_app_server_protocol::PluginReadResponse;
|
||||
use codex_app_server_protocol::PluginSkillReadParams;
|
||||
use codex_app_server_protocol::PluginSkillReadResponse;
|
||||
use codex_app_server_protocol::PluginSource;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
@@ -139,6 +141,7 @@ async fn plugin_read_rejects_remote_marketplace_when_remote_plugin_is_disabled()
|
||||
.message
|
||||
.contains("remote plugin read is not enabled")
|
||||
);
|
||||
assert!(err.error.message.contains("chatgpt-global"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -252,7 +255,7 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() ->
|
||||
let request_id = mcp
|
||||
.send_plugin_read_request(PluginReadParams {
|
||||
marketplace_path: None,
|
||||
remote_marketplace_name: Some("caller-marketplace-is-ignored".to_string()),
|
||||
remote_marketplace_name: Some("chatgpt-global".to_string()),
|
||||
plugin_name: "plugins~Plugin_00000000000000000000000000000000".to_string(),
|
||||
})
|
||||
.await?;
|
||||
@@ -286,6 +289,70 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_skill_read_reads_remote_skill_contents_when_remote_plugin_enabled() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let server = MockServer::start().await;
|
||||
write_remote_plugin_catalog_config(
|
||||
codex_home.path(),
|
||||
&format!("{}/backend-api/", server.uri()),
|
||||
)?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("chatgpt-token")
|
||||
.account_id("account-123")
|
||||
.chatgpt_user_id("user-123")
|
||||
.chatgpt_account_id("account-123"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
let skill_body = r##"{
|
||||
"plugin_id": "plugins~Plugin_00000000000000000000000000000000",
|
||||
"status": "ENABLED",
|
||||
"plugin_release_id": "release-1",
|
||||
"name": "plan-work",
|
||||
"description": "Plan work from Linear issues",
|
||||
"plugin_release_skill_id": "skill-1",
|
||||
"skill_md_contents": "# Plan Work\n\nUse Linear issues to create a plan."
|
||||
}"##;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path(
|
||||
"/backend-api/ps/plugins/plugins~Plugin_00000000000000000000000000000000/skills/plan-work",
|
||||
))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(skill_body))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_skill_read_request(PluginSkillReadParams {
|
||||
remote_marketplace_name: "chatgpt-global".to_string(),
|
||||
remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(),
|
||||
skill_name: "plan-work".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginSkillReadResponse = to_response(response)?;
|
||||
|
||||
assert_eq!(
|
||||
response,
|
||||
PluginSkillReadResponse {
|
||||
contents: Some("# Plan Work\n\nUse Linear issues to create a plan.".to_string()),
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_read_maps_missing_remote_plugin_to_invalid_request() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -412,6 +479,11 @@ async fn plugin_read_rejects_invalid_remote_plugin_name() -> Result<()> {
|
||||
|
||||
assert_eq!(err.error.code, -32600);
|
||||
assert!(err.error.message.contains("invalid remote plugin id"));
|
||||
assert!(
|
||||
err.error
|
||||
.message
|
||||
.contains("only ASCII letters, digits, `_`, `-`, and `~` are allowed")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ use codex_app_server_protocol::PluginAuthPolicy;
|
||||
use codex_app_server_protocol::PluginInstallPolicy;
|
||||
use codex_app_server_protocol::PluginInterface;
|
||||
use codex_app_server_protocol::PluginShareDeleteResponse;
|
||||
use codex_app_server_protocol::PluginShareListItem;
|
||||
use codex_app_server_protocol::PluginShareListResponse;
|
||||
use codex_app_server_protocol::PluginShareSaveResponse;
|
||||
use codex_app_server_protocol::PluginSource;
|
||||
@@ -49,6 +50,7 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> {
|
||||
.chatgpt_account_id("account-123"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
write_corrupt_plugin_share_local_path_mapping(codex_home.path())?;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/backend-api/public/plugins/workspace/upload-url"))
|
||||
@@ -88,11 +90,12 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> {
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
let expected_plugin_path = AbsolutePathBuf::try_from(plugin_path.clone())?;
|
||||
let request_id = mcp
|
||||
.send_raw_request(
|
||||
"plugin/share/save",
|
||||
Some(json!({
|
||||
"pluginPath": AbsolutePathBuf::try_from(plugin_path)?,
|
||||
"pluginPath": expected_plugin_path.clone(),
|
||||
})),
|
||||
)
|
||||
.await?;
|
||||
@@ -111,6 +114,62 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> {
|
||||
share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(),
|
||||
}
|
||||
);
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/ps/plugins/workspace/created"))
|
||||
.and(query_param("limit", "200"))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"plugins": [remote_plugin_json("plugins_123")],
|
||||
"pagination": empty_pagination_json(),
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/ps/plugins/installed"))
|
||||
.and(query_param("scope", "WORKSPACE"))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"plugins": [installed_remote_plugin_json("plugins_123")],
|
||||
"pagination": empty_pagination_json(),
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let request_id = mcp
|
||||
.send_raw_request("plugin/share/list", Some(json!({})))
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginShareListResponse = to_response(response)?;
|
||||
|
||||
assert_eq!(
|
||||
response,
|
||||
PluginShareListResponse {
|
||||
data: vec![PluginShareListItem {
|
||||
plugin: PluginSummary {
|
||||
id: "plugins_123".to_string(),
|
||||
name: "demo-plugin".to_string(),
|
||||
source: PluginSource::Remote,
|
||||
installed: true,
|
||||
enabled: true,
|
||||
install_policy: PluginInstallPolicy::Available,
|
||||
auth_policy: PluginAuthPolicy::OnUse,
|
||||
availability: codex_app_server_protocol::PluginAvailability::Available,
|
||||
interface: Some(expected_plugin_interface()),
|
||||
},
|
||||
share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(),
|
||||
local_plugin_path: Some(expected_plugin_path),
|
||||
}],
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -169,16 +228,20 @@ async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> {
|
||||
assert_eq!(
|
||||
response,
|
||||
PluginShareListResponse {
|
||||
data: vec![PluginSummary {
|
||||
id: "plugins_123".to_string(),
|
||||
name: "demo-plugin".to_string(),
|
||||
source: PluginSource::Remote,
|
||||
installed: true,
|
||||
enabled: true,
|
||||
install_policy: PluginInstallPolicy::Available,
|
||||
auth_policy: PluginAuthPolicy::OnUse,
|
||||
availability: codex_app_server_protocol::PluginAvailability::Available,
|
||||
interface: Some(expected_plugin_interface()),
|
||||
data: vec![PluginShareListItem {
|
||||
plugin: PluginSummary {
|
||||
id: "plugins_123".to_string(),
|
||||
name: "demo-plugin".to_string(),
|
||||
source: PluginSource::Remote,
|
||||
installed: true,
|
||||
enabled: true,
|
||||
install_policy: PluginInstallPolicy::Available,
|
||||
auth_policy: PluginAuthPolicy::OnUse,
|
||||
availability: codex_app_server_protocol::PluginAvailability::Available,
|
||||
interface: Some(expected_plugin_interface()),
|
||||
},
|
||||
share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(),
|
||||
local_plugin_path: None,
|
||||
}],
|
||||
}
|
||||
);
|
||||
@@ -198,6 +261,8 @@ async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> {
|
||||
.chatgpt_account_id("account-123"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
let local_plugin_path = AbsolutePathBuf::try_from(codex_home.path().join("local-plugin"))?;
|
||||
write_plugin_share_local_path_mapping(codex_home.path(), "plugins_123", &local_plugin_path)?;
|
||||
|
||||
Mock::given(method("DELETE"))
|
||||
.and(path("/backend-api/public/plugins/workspace/plugins_123"))
|
||||
@@ -227,6 +292,62 @@ async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> {
|
||||
let response: PluginShareDeleteResponse = to_response(response)?;
|
||||
|
||||
assert_eq!(response, PluginShareDeleteResponse {});
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/ps/plugins/workspace/created"))
|
||||
.and(query_param("limit", "200"))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"plugins": [remote_plugin_json("plugins_123")],
|
||||
"pagination": empty_pagination_json(),
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/ps/plugins/installed"))
|
||||
.and(query_param("scope", "WORKSPACE"))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"plugins": [installed_remote_plugin_json("plugins_123")],
|
||||
"pagination": empty_pagination_json(),
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let request_id = mcp
|
||||
.send_raw_request("plugin/share/list", Some(json!({})))
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginShareListResponse = to_response(response)?;
|
||||
|
||||
assert_eq!(
|
||||
response,
|
||||
PluginShareListResponse {
|
||||
data: vec![PluginShareListItem {
|
||||
plugin: PluginSummary {
|
||||
id: "plugins_123".to_string(),
|
||||
name: "demo-plugin".to_string(),
|
||||
source: PluginSource::Remote,
|
||||
installed: true,
|
||||
enabled: true,
|
||||
install_policy: PluginInstallPolicy::Available,
|
||||
auth_policy: PluginAuthPolicy::OnUse,
|
||||
availability: codex_app_server_protocol::PluginAvailability::Available,
|
||||
interface: Some(expected_plugin_interface()),
|
||||
},
|
||||
share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(),
|
||||
local_plugin_path: None,
|
||||
}],
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -250,6 +371,7 @@ fn remote_plugin_json(plugin_id: &str) -> serde_json::Value {
|
||||
"id": plugin_id,
|
||||
"name": "demo-plugin",
|
||||
"scope": "WORKSPACE",
|
||||
"share_url": "https://chatgpt.example/plugins/share/share-key-1",
|
||||
"installation_policy": "AVAILABLE",
|
||||
"authentication_policy": "ON_USE",
|
||||
"release": {
|
||||
@@ -315,6 +437,33 @@ fn write_test_plugin(root: &Path, plugin_name: &str) -> std::io::Result<PathBuf>
|
||||
Ok(plugin_path)
|
||||
}
|
||||
|
||||
fn write_corrupt_plugin_share_local_path_mapping(codex_home: &Path) -> std::io::Result<()> {
|
||||
write_file(
|
||||
&codex_home.join(".tmp/plugin-share-local-paths-v1.json"),
|
||||
"not-json",
|
||||
)
|
||||
}
|
||||
|
||||
fn write_plugin_share_local_path_mapping(
|
||||
codex_home: &Path,
|
||||
remote_plugin_id: &str,
|
||||
plugin_path: &AbsolutePathBuf,
|
||||
) -> std::io::Result<()> {
|
||||
let mut local_plugin_paths_by_remote_plugin_id = serde_json::Map::new();
|
||||
local_plugin_paths_by_remote_plugin_id.insert(
|
||||
remote_plugin_id.to_string(),
|
||||
serde_json::to_value(plugin_path).map_err(std::io::Error::other)?,
|
||||
);
|
||||
let contents = serde_json::to_string_pretty(&json!({
|
||||
"localPluginPathsByRemotePluginId": local_plugin_paths_by_remote_plugin_id,
|
||||
}))
|
||||
.map_err(std::io::Error::other)?;
|
||||
write_file(
|
||||
&codex_home.join(".tmp/plugin-share-local-paths-v1.json"),
|
||||
&format!("{contents}\n"),
|
||||
)
|
||||
}
|
||||
|
||||
fn write_file(path: &Path, contents: &str) -> std::io::Result<()> {
|
||||
let Some(parent) = path.parent() else {
|
||||
return Err(std::io::Error::other(format!(
|
||||
|
||||
@@ -48,6 +48,7 @@ use codex_protocol::models::BaseInstructions;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::SessionSource as ProtocolSessionSource;
|
||||
use codex_protocol::protocol::ThreadMemoryMode;
|
||||
use codex_protocol::protocol::UserMessageEvent;
|
||||
use codex_protocol::user_input::ByteRange;
|
||||
use codex_protocol::user_input::TextElement;
|
||||
@@ -56,6 +57,7 @@ use codex_thread_store::CreateThreadParams;
|
||||
use codex_thread_store::InMemoryThreadStore;
|
||||
use codex_thread_store::ThreadEventPersistenceMode;
|
||||
use codex_thread_store::ThreadMetadataPatch;
|
||||
use codex_thread_store::ThreadPersistenceMetadata;
|
||||
use codex_thread_store::ThreadStore;
|
||||
use codex_thread_store::UpdateThreadMetadataParams;
|
||||
use core_test_support::responses;
|
||||
@@ -1028,6 +1030,11 @@ async fn seed_pathless_store_thread(
|
||||
source: ProtocolSessionSource::Cli,
|
||||
base_instructions: BaseInstructions::default(),
|
||||
dynamic_tools: Vec::new(),
|
||||
metadata: ThreadPersistenceMetadata {
|
||||
cwd: None,
|
||||
model_provider: "test-provider".to_string(),
|
||||
memory_mode: ThreadMemoryMode::Disabled,
|
||||
},
|
||||
event_persistence_mode: ThreadEventPersistenceMode::default(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -71,6 +71,7 @@ use core_test_support::skip_if_no_network;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::fs::FileTimes;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
@@ -1669,7 +1670,7 @@ async fn thread_resume_rejects_history_when_thread_is_running() -> Result<()> {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_resume_rejects_mismatched_path_when_thread_is_running() -> Result<()> {
|
||||
async fn thread_resume_uses_path_over_thread_id_when_thread_is_running() -> Result<()> {
|
||||
let server = responses::start_mock_server().await;
|
||||
let first_body = responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
@@ -1749,24 +1750,71 @@ async fn thread_resume_rejects_mismatched_path_when_thread_is_running() -> Resul
|
||||
)
|
||||
.await??;
|
||||
|
||||
let resume_id = primary
|
||||
let other_thread_id = ThreadId::new().to_string();
|
||||
let stale_path = rollout_path(codex_home.path(), "2025-01-01T00-00-00", &thread_id);
|
||||
std::fs::create_dir_all(stale_path.parent().expect("stale path parent"))?;
|
||||
let thread_uuid = Uuid::parse_str(&thread_id)?;
|
||||
let mut stale_file = std::fs::File::create(&stale_path)?;
|
||||
let stale_meta = json!({
|
||||
"timestamp": "2025-01-01T00:00:00Z",
|
||||
"type": "session_meta",
|
||||
"payload": {
|
||||
"id": thread_uuid,
|
||||
"timestamp": "2025-01-01T00:00:00Z",
|
||||
"cwd": codex_home.path(),
|
||||
"originator": "test_originator",
|
||||
"cli_version": "test_version",
|
||||
"source": "cli",
|
||||
"model_provider": "test-provider",
|
||||
},
|
||||
});
|
||||
writeln!(stale_file, "{stale_meta}")?;
|
||||
let stale_user_event = json!({
|
||||
"timestamp": "2025-01-01T00:00:00Z",
|
||||
"type": "event_msg",
|
||||
"payload": {
|
||||
"type": "user_message",
|
||||
"message": "stale history",
|
||||
"kind": "plain",
|
||||
},
|
||||
});
|
||||
writeln!(stale_file, "{stale_user_event}")?;
|
||||
|
||||
let stale_resume_id = primary
|
||||
.send_thread_resume_request(ThreadResumeParams {
|
||||
thread_id: thread_id.clone(),
|
||||
path: Some(PathBuf::from("/tmp/does-not-match-running-rollout.jsonl")),
|
||||
thread_id: other_thread_id.clone(),
|
||||
path: Some(stale_path),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let resume_err: JSONRPCError = timeout(
|
||||
let stale_resume_err: JSONRPCError = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
primary.read_stream_until_error_message(RequestId::Integer(resume_id)),
|
||||
primary.read_stream_until_error_message(RequestId::Integer(stale_resume_id)),
|
||||
)
|
||||
.await??;
|
||||
assert!(
|
||||
resume_err.error.message.contains("mismatched path"),
|
||||
stale_resume_err.error.message.contains("stale path"),
|
||||
"unexpected resume error: {}",
|
||||
resume_err.error.message
|
||||
stale_resume_err.error.message
|
||||
);
|
||||
|
||||
let resume_by_path_id = primary
|
||||
.send_thread_resume_request(ThreadResumeParams {
|
||||
thread_id: other_thread_id.clone(),
|
||||
path: thread.path,
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let resume_by_path_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
primary.read_stream_until_response_message(RequestId::Integer(resume_by_path_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadResumeResponse {
|
||||
thread: resumed, ..
|
||||
} = to_response::<ThreadResumeResponse>(resume_by_path_resp)?;
|
||||
assert_eq!(resumed.id, thread_id);
|
||||
|
||||
primary
|
||||
.interrupt_turn_and_wait_for_aborted(thread_id, running_turn.id, DEFAULT_READ_TIMEOUT)
|
||||
.await?;
|
||||
@@ -2463,7 +2511,7 @@ async fn thread_resume_surfaces_cloud_requirements_load_errors() -> Result<()> {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_resume_prefers_path_over_thread_id() -> Result<()> {
|
||||
async fn thread_resume_uses_path_over_invalid_thread_id() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), &server.uri())?;
|
||||
@@ -2523,13 +2571,6 @@ async fn thread_resume_prefers_path_over_thread_id() -> Result<()> {
|
||||
thread: resumed, ..
|
||||
} = to_response::<ThreadResumeResponse>(resume_resp)?;
|
||||
assert_eq!(resumed.id, thread.id);
|
||||
let resumed_path = resumed.path.as_ref().expect("resumed thread path");
|
||||
let original_path = thread.path.as_ref().expect("original thread path");
|
||||
assert_eq!(
|
||||
normalized_existing_path(resumed_path)?,
|
||||
normalized_existing_path(original_path)?
|
||||
);
|
||||
assert_eq!(resumed.status, ThreadStatus::Idle);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ where
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::future::pending;
|
||||
use std::time::Duration;
|
||||
use tokio::task;
|
||||
use tokio::time::sleep;
|
||||
@@ -58,12 +59,7 @@ mod tests {
|
||||
token_clone.cancel();
|
||||
});
|
||||
|
||||
let result = async {
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
7
|
||||
}
|
||||
.or_cancel(&token)
|
||||
.await;
|
||||
let result = pending::<i32>().or_cancel(&token).await;
|
||||
|
||||
cancel_handle.await.expect("cancel task panicked");
|
||||
assert_eq!(Err(CancelErr::Cancelled), result);
|
||||
|
||||
@@ -65,6 +65,28 @@ const RESERVED_MODEL_PROVIDER_IDS: [&str; 4] = [
|
||||
LMSTUDIO_OSS_PROVIDER_ID,
|
||||
];
|
||||
|
||||
pub const DEFAULT_PROJECT_DOC_MAX_BYTES: usize = 32 * 1024;
|
||||
|
||||
const fn default_allow_login_shell() -> Option<bool> {
|
||||
Some(true)
|
||||
}
|
||||
|
||||
fn default_history() -> Option<History> {
|
||||
Some(History::default())
|
||||
}
|
||||
|
||||
const fn default_project_doc_max_bytes() -> Option<usize> {
|
||||
Some(DEFAULT_PROJECT_DOC_MAX_BYTES)
|
||||
}
|
||||
|
||||
fn default_project_doc_fallback_filenames() -> Option<Vec<String>> {
|
||||
Some(Vec::new())
|
||||
}
|
||||
|
||||
const fn default_hide_agent_reasoning() -> Option<bool> {
|
||||
Some(false)
|
||||
}
|
||||
|
||||
/// Base config deserialized from ~/.codex/config.toml.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
@@ -106,6 +128,7 @@ pub struct ConfigToml {
|
||||
/// If `false`, the model can never use a login shell: `login = true`
|
||||
/// requests are rejected, and omitting `login` defaults to a non-login
|
||||
/// shell.
|
||||
#[serde(default = "default_allow_login_shell")]
|
||||
pub allow_login_shell: Option<bool>,
|
||||
|
||||
/// Sandbox mode to use.
|
||||
@@ -202,9 +225,11 @@ pub struct ConfigToml {
|
||||
pub model_providers: HashMap<String, ModelProviderInfo>,
|
||||
|
||||
/// Maximum number of bytes to include from an AGENTS.md project doc file.
|
||||
#[serde(default = "default_project_doc_max_bytes")]
|
||||
pub project_doc_max_bytes: Option<usize>,
|
||||
|
||||
/// Ordered list of fallback filenames to look for when AGENTS.md is missing.
|
||||
#[serde(default = "default_project_doc_fallback_filenames")]
|
||||
pub project_doc_fallback_filenames: Option<Vec<String>>,
|
||||
|
||||
/// Token budget applied when storing tool/function outputs in the context manager.
|
||||
@@ -233,7 +258,7 @@ pub struct ConfigToml {
|
||||
pub profiles: HashMap<String, ConfigProfile>,
|
||||
|
||||
/// Settings that govern if and what will be written to `~/.codex/history.jsonl`.
|
||||
#[serde(default)]
|
||||
#[serde(default = "default_history")]
|
||||
pub history: Option<History>,
|
||||
|
||||
/// Directory where Codex stores the SQLite state DB.
|
||||
@@ -244,6 +269,9 @@ pub struct ConfigToml {
|
||||
/// Defaults to `$CODEX_HOME/log`.
|
||||
pub log_dir: Option<AbsolutePathBuf>,
|
||||
|
||||
/// Debugging and reproducibility settings.
|
||||
pub debug: Option<DebugToml>,
|
||||
|
||||
/// Optional URI-based file opener. If set, citations to files in the model
|
||||
/// output will be hyperlinked using the specified URI scheme.
|
||||
pub file_opener: Option<UriBasedFileOpener>,
|
||||
@@ -253,6 +281,7 @@ pub struct ConfigToml {
|
||||
|
||||
/// When set to `true`, `AgentReasoning` events will be hidden from the
|
||||
/// UI/output. Defaults to `false`.
|
||||
#[serde(default = "default_hide_agent_reasoning")]
|
||||
pub hide_agent_reasoning: Option<bool>,
|
||||
|
||||
/// When set to `true`, `AgentReasoningRawContentEvent` events will be shown in the UI/output.
|
||||
@@ -420,6 +449,38 @@ pub struct ConfigToml {
|
||||
pub oss_provider: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ConfigLockfileToml {
|
||||
pub version: u32,
|
||||
pub codex_version: String,
|
||||
|
||||
/// Replayable effective config captured in the lockfile.
|
||||
pub config: ConfigToml,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct DebugToml {
|
||||
pub config_lockfile: Option<DebugConfigLockToml>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct DebugConfigLockToml {
|
||||
/// Directory where Codex writes effective session config lock files.
|
||||
pub export_dir: Option<AbsolutePathBuf>,
|
||||
|
||||
/// Lockfile to replay as the authoritative effective config.
|
||||
pub load_path: Option<AbsolutePathBuf>,
|
||||
|
||||
/// Allow replaying a lock generated by a different Codex version.
|
||||
pub allow_codex_version_mismatch: Option<bool>,
|
||||
|
||||
/// Save fields resolved from the model catalog/session configuration.
|
||||
pub save_fields_resolved_from_model_catalog: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ThreadStoreToml {
|
||||
|
||||
@@ -136,6 +136,7 @@ impl UriBasedFileOpener {
|
||||
|
||||
/// Settings that govern if and what will be written to `~/.codex/history.jsonl`.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
|
||||
#[serde(default)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct History {
|
||||
/// If true, history entries will not be written to disk.
|
||||
@@ -262,7 +263,7 @@ pub struct MemoriesToml {
|
||||
}
|
||||
|
||||
/// Effective memories settings after defaults are applied.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct MemoriesConfig {
|
||||
pub disable_on_external_context: bool,
|
||||
pub generate_memories: bool,
|
||||
@@ -634,6 +635,11 @@ pub struct Tui {
|
||||
#[serde(default)]
|
||||
pub status_line: Option<Vec<String>>,
|
||||
|
||||
/// Color status line items with colors derived from the active syntax theme.
|
||||
/// Defaults to `true`.
|
||||
#[serde(default = "default_true")]
|
||||
pub status_line_use_colors: bool,
|
||||
|
||||
/// Ordered list of terminal title item identifiers.
|
||||
///
|
||||
/// When set, the TUI renders the selected items into the terminal window/tab title.
|
||||
|
||||
@@ -387,8 +387,6 @@ pub struct PluginsManager {
|
||||
configured_marketplace_upgrade_state: RwLock<ConfiguredMarketplaceUpgradeState>,
|
||||
non_curated_cache_refresh_state: RwLock<NonCuratedCacheRefreshState>,
|
||||
cached_enabled_outcome: RwLock<Option<CachedPluginLoadOutcome>>,
|
||||
// TODO(remote plugins): reset this cache when ChatGPT auth/account state changes so stale
|
||||
// remote installed state cannot remain effective for a different account.
|
||||
remote_installed_plugins_cache: RwLock<Option<Vec<RemoteInstalledPlugin>>>,
|
||||
remote_installed_plugins_cache_refresh_state: RwLock<RemoteInstalledPluginsCacheRefreshState>,
|
||||
remote_sync_lock: Semaphore,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::store::PLUGINS_CACHE_DIR;
|
||||
use crate::store::PluginStore;
|
||||
use codex_app_server_protocol::JSONRPCErrorError;
|
||||
use codex_app_server_protocol::PluginAuthPolicy;
|
||||
use codex_app_server_protocol::PluginAvailability;
|
||||
use codex_app_server_protocol::PluginInstallPolicy;
|
||||
@@ -8,6 +9,7 @@ use codex_app_server_protocol::SkillInterface;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_login::default_client::build_reqwest_client;
|
||||
use codex_plugin::PluginId;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use reqwest::RequestBuilder;
|
||||
use serde::Deserialize;
|
||||
use std::collections::BTreeMap;
|
||||
@@ -16,6 +18,7 @@ use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
|
||||
mod remote_installed_plugin_sync;
|
||||
mod share;
|
||||
@@ -39,6 +42,7 @@ pub const REMOTE_WORKSPACE_MARKETPLACE_DISPLAY_NAME: &str = "ChatGPT Workspace P
|
||||
const REMOTE_PLUGIN_CATALOG_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const REMOTE_PLUGIN_LIST_PAGE_LIMIT: u32 = 200;
|
||||
const MAX_REMOTE_DEFAULT_PROMPT_LEN: usize = 128;
|
||||
const INVALID_REQUEST_ERROR_CODE: i64 = -32600;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RemotePluginServiceConfig {
|
||||
@@ -72,6 +76,13 @@ pub struct RemotePluginSummary {
|
||||
pub interface: Option<PluginInterface>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct RemotePluginShareSummary {
|
||||
pub summary: RemotePluginSummary,
|
||||
pub share_url: Option<String>,
|
||||
pub local_plugin_path: Option<AbsolutePathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct RemotePluginDetail {
|
||||
pub marketplace_name: String,
|
||||
@@ -93,6 +104,32 @@ pub struct RemotePluginSkill {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct RemotePluginSkillDetail {
|
||||
pub contents: Option<String>,
|
||||
}
|
||||
|
||||
pub fn is_valid_remote_plugin_id(plugin_id: &str) -> bool {
|
||||
!plugin_id.is_empty()
|
||||
&& plugin_id
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '~')
|
||||
}
|
||||
|
||||
pub fn validate_remote_plugin_id(plugin_id: &str) -> Result<(), JSONRPCErrorError> {
|
||||
if !is_valid_remote_plugin_id(plugin_id) {
|
||||
return Err(JSONRPCErrorError {
|
||||
code: INVALID_REQUEST_ERROR_CODE,
|
||||
message:
|
||||
"invalid remote plugin id: only ASCII letters, digits, `_`, `-`, and `~` are allowed"
|
||||
.to_string(),
|
||||
data: None,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum RemotePluginCatalogError {
|
||||
#[error("chatgpt authentication required for remote plugin catalog")]
|
||||
@@ -127,11 +164,25 @@ pub enum RemotePluginCatalogError {
|
||||
source: serde_json::Error,
|
||||
},
|
||||
|
||||
#[error("invalid remote plugin catalog base URL: {0}")]
|
||||
InvalidBaseUrl(#[source] url::ParseError),
|
||||
|
||||
#[error("invalid remote plugin catalog base URL path")]
|
||||
InvalidBaseUrlPath,
|
||||
|
||||
#[error("remote marketplace `{marketplace_name}` is not supported")]
|
||||
UnknownMarketplace { marketplace_name: String },
|
||||
|
||||
#[error(
|
||||
"remote plugin mutation returned unexpected plugin id: expected `{expected}`, got `{actual}`"
|
||||
)]
|
||||
UnexpectedPluginId { expected: String, actual: String },
|
||||
|
||||
#[error(
|
||||
"remote plugin skill response returned unexpected skill name: expected `{expected}`, got `{actual}`"
|
||||
)]
|
||||
UnexpectedSkillName { expected: String, actual: String },
|
||||
|
||||
#[error(
|
||||
"remote plugin mutation returned unexpected enabled state for `{plugin_id}`: expected {expected_enabled}, got {actual_enabled}"
|
||||
)]
|
||||
@@ -202,6 +253,14 @@ impl RemotePluginScope {
|
||||
Self::Workspace => REMOTE_WORKSPACE_MARKETPLACE_DISPLAY_NAME,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_marketplace_name(name: &str) -> Option<Self> {
|
||||
match name {
|
||||
REMOTE_GLOBAL_MARKETPLACE_NAME => Some(Self::Global),
|
||||
REMOTE_WORKSPACE_MARKETPLACE_NAME => Some(Self::Workspace),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
@@ -226,6 +285,13 @@ struct RemotePluginSkillResponse {
|
||||
interface: Option<RemotePluginSkillInterfaceResponse>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
struct RemotePluginSkillDetailResponse {
|
||||
plugin_id: String,
|
||||
name: String,
|
||||
skill_md_contents: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
struct RemotePluginReleaseInterfaceResponse {
|
||||
short_description: Option<String>,
|
||||
@@ -265,6 +331,8 @@ struct RemotePluginDirectoryItem {
|
||||
id: String,
|
||||
name: String,
|
||||
scope: RemotePluginScope,
|
||||
#[serde(default)]
|
||||
share_url: Option<String>,
|
||||
installation_policy: PluginInstallPolicy,
|
||||
authentication_policy: PluginAuthPolicy,
|
||||
#[serde(rename = "status", default)]
|
||||
@@ -462,6 +530,42 @@ pub async fn fetch_remote_plugin_detail_with_download_urls(
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn fetch_remote_plugin_skill_detail(
|
||||
config: &RemotePluginServiceConfig,
|
||||
auth: Option<&CodexAuth>,
|
||||
marketplace_name: &str,
|
||||
plugin_id: &str,
|
||||
skill_name: &str,
|
||||
) -> Result<RemotePluginSkillDetail, RemotePluginCatalogError> {
|
||||
let auth = ensure_chatgpt_auth(auth)?;
|
||||
if RemotePluginScope::from_marketplace_name(marketplace_name).is_none() {
|
||||
return Err(RemotePluginCatalogError::UnknownMarketplace {
|
||||
marketplace_name: marketplace_name.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let url = remote_plugin_skill_detail_url(config, plugin_id, skill_name)?;
|
||||
let client = build_reqwest_client();
|
||||
let request = authenticated_request(client.get(&url), auth)?;
|
||||
let response: RemotePluginSkillDetailResponse = send_and_decode(request, &url).await?;
|
||||
if response.plugin_id != plugin_id {
|
||||
return Err(RemotePluginCatalogError::UnexpectedPluginId {
|
||||
expected: plugin_id.to_string(),
|
||||
actual: response.plugin_id,
|
||||
});
|
||||
}
|
||||
if response.name != skill_name {
|
||||
return Err(RemotePluginCatalogError::UnexpectedSkillName {
|
||||
expected: skill_name.to_string(),
|
||||
actual: response.name,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(RemotePluginSkillDetail {
|
||||
contents: response.skill_md_contents,
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_remote_plugin_detail_with_download_url_option(
|
||||
config: &RemotePluginServiceConfig,
|
||||
auth: Option<&CodexAuth>,
|
||||
@@ -883,6 +987,27 @@ async fn fetch_plugin_detail(
|
||||
send_and_decode(request, &url).await
|
||||
}
|
||||
|
||||
fn remote_plugin_skill_detail_url(
|
||||
config: &RemotePluginServiceConfig,
|
||||
plugin_id: &str,
|
||||
skill_name: &str,
|
||||
) -> Result<String, RemotePluginCatalogError> {
|
||||
let mut url = Url::parse(config.chatgpt_base_url.trim_end_matches('/'))
|
||||
.map_err(RemotePluginCatalogError::InvalidBaseUrl)?;
|
||||
{
|
||||
let mut segments = url
|
||||
.path_segments_mut()
|
||||
.map_err(|()| RemotePluginCatalogError::InvalidBaseUrlPath)?;
|
||||
segments.pop_if_empty();
|
||||
segments.push("ps");
|
||||
segments.push("plugins");
|
||||
segments.push(plugin_id);
|
||||
segments.push("skills");
|
||||
segments.push(skill_name);
|
||||
}
|
||||
Ok(url.to_string())
|
||||
}
|
||||
|
||||
fn ensure_chatgpt_auth(auth: Option<&CodexAuth>) -> Result<&CodexAuth, RemotePluginCatalogError> {
|
||||
let Some(auth) = auth else {
|
||||
return Err(RemotePluginCatalogError::AuthRequired);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::*;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_login::default_client::build_reqwest_client;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use flate2::Compression;
|
||||
use flate2::write::GzEncoder;
|
||||
use reqwest::RequestBuilder;
|
||||
@@ -13,6 +14,9 @@ use std::fs;
|
||||
use std::io;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use tracing::warn;
|
||||
|
||||
mod local_paths;
|
||||
|
||||
const REMOTE_PLUGIN_SHARE_MAX_ARCHIVE_BYTES: usize = 50 * 1024 * 1024;
|
||||
|
||||
@@ -53,14 +57,15 @@ struct RemoteWorkspacePluginCreateResponse {
|
||||
pub async fn save_remote_plugin_share(
|
||||
config: &RemotePluginServiceConfig,
|
||||
auth: Option<&CodexAuth>,
|
||||
plugin_path: &Path,
|
||||
codex_home: &Path,
|
||||
plugin_path: &AbsolutePathBuf,
|
||||
remote_plugin_id: Option<&str>,
|
||||
) -> Result<RemotePluginShareSaveResult, RemotePluginCatalogError> {
|
||||
let auth = ensure_chatgpt_auth(auth)?;
|
||||
let plugin_path = plugin_path.to_path_buf();
|
||||
let plugin_path_for_archive = plugin_path.as_path().to_path_buf();
|
||||
let (filename, archive_bytes) = tokio::task::spawn_blocking(move || {
|
||||
let filename = archive_filename(&plugin_path)?;
|
||||
let archive_bytes = archive_plugin_for_upload(&plugin_path)?;
|
||||
let filename = archive_filename(&plugin_path_for_archive)?;
|
||||
let archive_bytes = archive_plugin_for_upload(&plugin_path_for_archive)?;
|
||||
Ok::<_, RemotePluginCatalogError>((filename, archive_bytes))
|
||||
})
|
||||
.await
|
||||
@@ -93,6 +98,17 @@ pub async fn save_remote_plugin_share(
|
||||
));
|
||||
}
|
||||
|
||||
if let Err(err) = local_paths::record_plugin_share_local_path(
|
||||
codex_home,
|
||||
&response.plugin_id,
|
||||
plugin_path.clone(),
|
||||
) {
|
||||
warn!(
|
||||
remote_plugin_id = %response.plugin_id,
|
||||
"failed to record plugin share local path mapping: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(RemotePluginShareSaveResult {
|
||||
remote_plugin_id: response.plugin_id,
|
||||
share_url: response.share_url,
|
||||
@@ -102,7 +118,8 @@ pub async fn save_remote_plugin_share(
|
||||
pub async fn list_remote_plugin_shares(
|
||||
config: &RemotePluginServiceConfig,
|
||||
auth: Option<&CodexAuth>,
|
||||
) -> Result<Vec<RemotePluginSummary>, RemotePluginCatalogError> {
|
||||
codex_home: &Path,
|
||||
) -> Result<Vec<RemotePluginShareSummary>, RemotePluginCatalogError> {
|
||||
let auth = ensure_chatgpt_auth(auth)?;
|
||||
let created_plugins = fetch_created_workspace_plugins(config, auth).await?;
|
||||
if created_plugins.is_empty() {
|
||||
@@ -115,16 +132,30 @@ pub async fn list_remote_plugin_shares(
|
||||
.into_iter()
|
||||
.map(|plugin| (plugin.plugin.id.clone(), plugin))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let local_plugin_paths =
|
||||
local_paths::load_plugin_share_local_paths(codex_home).unwrap_or_else(|err| {
|
||||
warn!("failed to load plugin share local path mapping: {err}");
|
||||
BTreeMap::new()
|
||||
});
|
||||
|
||||
Ok(created_plugins
|
||||
.into_iter()
|
||||
.map(|plugin| build_remote_plugin_summary(&plugin, installed_by_id.get(&plugin.id)))
|
||||
.map(|plugin| {
|
||||
let summary = build_remote_plugin_summary(&plugin, installed_by_id.get(&plugin.id));
|
||||
let local_plugin_path = local_plugin_paths.get(&plugin.id).cloned();
|
||||
RemotePluginShareSummary {
|
||||
summary,
|
||||
share_url: plugin.share_url,
|
||||
local_plugin_path,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn delete_remote_plugin_share(
|
||||
config: &RemotePluginServiceConfig,
|
||||
auth: Option<&CodexAuth>,
|
||||
codex_home: &Path,
|
||||
remote_plugin_id: &str,
|
||||
) -> Result<(), RemotePluginCatalogError> {
|
||||
let auth = ensure_chatgpt_auth(auth)?;
|
||||
@@ -132,7 +163,14 @@ pub async fn delete_remote_plugin_share(
|
||||
let url = format!("{base_url}/public/plugins/workspace/{remote_plugin_id}");
|
||||
let client = build_reqwest_client();
|
||||
let request = authenticated_request(client.delete(&url), auth)?;
|
||||
send_and_expect_status(request, &url, &[StatusCode::NO_CONTENT]).await
|
||||
send_and_expect_status(request, &url, &[StatusCode::NO_CONTENT]).await?;
|
||||
if let Err(err) = local_paths::remove_plugin_share_local_path(codex_home, remote_plugin_id) {
|
||||
warn!(
|
||||
remote_plugin_id = %remote_plugin_id,
|
||||
"failed to remove plugin share local path mapping: {err}"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn fetch_created_workspace_plugins(
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::io;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
|
||||
const PLUGIN_SHARE_LOCAL_PATHS_FILE: &str = ".tmp/plugin-share-local-paths-v1.json";
|
||||
static PLUGIN_SHARE_LOCAL_PATHS_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
#[derive(Debug, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PluginShareLocalPaths {
|
||||
#[serde(default)]
|
||||
local_plugin_paths_by_remote_plugin_id: BTreeMap<String, AbsolutePathBuf>,
|
||||
}
|
||||
|
||||
pub(crate) fn load_plugin_share_local_paths(
|
||||
codex_home: &Path,
|
||||
) -> io::Result<BTreeMap<String, AbsolutePathBuf>> {
|
||||
let _guard = lock_plugin_share_local_paths()?;
|
||||
read_plugin_share_local_paths(codex_home)
|
||||
}
|
||||
|
||||
pub(crate) fn record_plugin_share_local_path(
|
||||
codex_home: &Path,
|
||||
remote_plugin_id: &str,
|
||||
plugin_path: AbsolutePathBuf,
|
||||
) -> io::Result<()> {
|
||||
let _guard = lock_plugin_share_local_paths()?;
|
||||
let mut mapping = read_plugin_share_local_paths_for_update(codex_home)?;
|
||||
mapping.insert(remote_plugin_id.to_string(), plugin_path);
|
||||
write_plugin_share_local_paths(codex_home, mapping)
|
||||
}
|
||||
|
||||
pub(crate) fn remove_plugin_share_local_path(
|
||||
codex_home: &Path,
|
||||
remote_plugin_id: &str,
|
||||
) -> io::Result<()> {
|
||||
let _guard = lock_plugin_share_local_paths()?;
|
||||
let mut mapping = read_plugin_share_local_paths_for_update(codex_home)?;
|
||||
mapping.remove(remote_plugin_id);
|
||||
write_plugin_share_local_paths(codex_home, mapping)
|
||||
}
|
||||
|
||||
fn lock_plugin_share_local_paths() -> io::Result<std::sync::MutexGuard<'static, ()>> {
|
||||
PLUGIN_SHARE_LOCAL_PATHS_LOCK
|
||||
.lock()
|
||||
.map_err(|err| io::Error::other(format!("plugin share local path lock poisoned: {err}")))
|
||||
}
|
||||
|
||||
fn read_plugin_share_local_paths(
|
||||
codex_home: &Path,
|
||||
) -> io::Result<BTreeMap<String, AbsolutePathBuf>> {
|
||||
let path = plugin_share_local_paths_path(codex_home);
|
||||
let contents = match std::fs::read_to_string(&path) {
|
||||
Ok(contents) => contents,
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
|
||||
let mapping = serde_json::from_str::<PluginShareLocalPaths>(&contents).map_err(|err| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"failed to parse plugin share local path mapping {}: {err}",
|
||||
path.display()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
Ok(mapping.local_plugin_paths_by_remote_plugin_id)
|
||||
}
|
||||
|
||||
fn read_plugin_share_local_paths_for_update(
|
||||
codex_home: &Path,
|
||||
) -> io::Result<BTreeMap<String, AbsolutePathBuf>> {
|
||||
match read_plugin_share_local_paths(codex_home) {
|
||||
Ok(mapping) => Ok(mapping),
|
||||
// This is a best-effort cache under .tmp, so malformed state should not
|
||||
// permanently block future share saves or deletes.
|
||||
Err(err) if err.kind() == io::ErrorKind::InvalidData => Ok(BTreeMap::new()),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_plugin_share_local_paths(
|
||||
codex_home: &Path,
|
||||
mapping: BTreeMap<String, AbsolutePathBuf>,
|
||||
) -> io::Result<()> {
|
||||
let path = plugin_share_local_paths_path(codex_home);
|
||||
if mapping.is_empty() {
|
||||
match std::fs::remove_file(&path) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
let contents = serde_json::to_string_pretty(&PluginShareLocalPaths {
|
||||
local_plugin_paths_by_remote_plugin_id: mapping,
|
||||
})
|
||||
.map_err(io::Error::other)?;
|
||||
write_atomically(&path, &format!("{contents}\n"))
|
||||
}
|
||||
|
||||
fn write_atomically(write_path: &Path, contents: &str) -> io::Result<()> {
|
||||
let parent = write_path.parent().ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("path {} has no parent directory", write_path.display()),
|
||||
)
|
||||
})?;
|
||||
std::fs::create_dir_all(parent)?;
|
||||
let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
|
||||
tmp.write_all(contents.as_bytes())?;
|
||||
tmp.persist(write_path).map_err(|err| err.error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn plugin_share_local_paths_path(codex_home: &Path) -> std::path::PathBuf {
|
||||
codex_home.join(PLUGIN_SHARE_LOCAL_PATHS_FILE)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ use codex_app_server_protocol::PluginAuthPolicy;
|
||||
use codex_app_server_protocol::PluginInstallPolicy;
|
||||
use codex_app_server_protocol::PluginInterface;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
@@ -49,6 +50,25 @@ fn write_test_plugin(root: &Path, plugin_name: &str) -> PathBuf {
|
||||
plugin_path
|
||||
}
|
||||
|
||||
fn write_plugin_share_local_path_mapping(
|
||||
codex_home: &Path,
|
||||
remote_plugin_id: &str,
|
||||
plugin_path: &AbsolutePathBuf,
|
||||
) {
|
||||
write_file(
|
||||
&codex_home.join(".tmp/plugin-share-local-paths-v1.json"),
|
||||
&format!(
|
||||
"{}\n",
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"localPluginPathsByRemotePluginId": {
|
||||
remote_plugin_id: plugin_path,
|
||||
},
|
||||
}))
|
||||
.unwrap()
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
fn archive_file_entries(archive_bytes: &[u8]) -> BTreeMap<String, Vec<u8>> {
|
||||
let decoder = flate2::read::GzDecoder::new(archive_bytes);
|
||||
let mut archive = tar::Archive::new(decoder);
|
||||
@@ -87,6 +107,18 @@ fn remote_plugin_json(plugin_id: &str) -> serde_json::Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn remote_plugin_json_with_share_url(
|
||||
plugin_id: &str,
|
||||
share_url: Option<&str>,
|
||||
) -> serde_json::Value {
|
||||
let mut plugin = remote_plugin_json(plugin_id);
|
||||
let serde_json::Value::Object(fields) = &mut plugin else {
|
||||
unreachable!("plugin json should be an object");
|
||||
};
|
||||
fields.insert("share_url".to_string(), json!(share_url));
|
||||
plugin
|
||||
}
|
||||
|
||||
fn installed_remote_plugin_json(plugin_id: &str) -> serde_json::Value {
|
||||
let mut plugin = remote_plugin_json(plugin_id);
|
||||
let serde_json::Value::Object(fields) = &mut plugin else {
|
||||
@@ -127,9 +159,13 @@ fn expected_plugin_interface() -> PluginInterface {
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_remote_plugin_share_creates_workspace_plugin() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let plugin_path = write_test_plugin(temp_dir.path(), "demo-plugin");
|
||||
let archive_size = archive_plugin_for_upload(&plugin_path).unwrap().len();
|
||||
let plugin_path =
|
||||
AbsolutePathBuf::try_from(write_test_plugin(temp_dir.path(), "demo-plugin")).unwrap();
|
||||
let archive_size = archive_plugin_for_upload(plugin_path.as_path())
|
||||
.unwrap()
|
||||
.len();
|
||||
let server = MockServer::start().await;
|
||||
let config = test_config(&server);
|
||||
let auth = test_auth();
|
||||
@@ -178,6 +214,7 @@ async fn save_remote_plugin_share_creates_workspace_plugin() {
|
||||
let result = save_remote_plugin_share(
|
||||
&config,
|
||||
Some(&auth),
|
||||
codex_home.path(),
|
||||
&plugin_path,
|
||||
/*remote_plugin_id*/ None,
|
||||
)
|
||||
@@ -191,6 +228,10 @@ async fn save_remote_plugin_share_creates_workspace_plugin() {
|
||||
share_url: Some("https://chatgpt.example/plugins/share/share-key-1".to_string()),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
local_paths::load_plugin_share_local_paths(codex_home.path()).unwrap(),
|
||||
BTreeMap::from([("plugins_123".to_string(), plugin_path)])
|
||||
);
|
||||
|
||||
let requests = server.received_requests().await.unwrap_or_default();
|
||||
let upload_request = requests
|
||||
@@ -261,9 +302,13 @@ fn archive_plugin_for_upload_places_manifest_at_archive_root() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_remote_plugin_share_updates_existing_workspace_plugin() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let plugin_path = write_test_plugin(temp_dir.path(), "demo-plugin");
|
||||
let archive_size = archive_plugin_for_upload(&plugin_path).unwrap().len();
|
||||
let plugin_path =
|
||||
AbsolutePathBuf::try_from(write_test_plugin(temp_dir.path(), "demo-plugin")).unwrap();
|
||||
let archive_size = archive_plugin_for_upload(plugin_path.as_path())
|
||||
.unwrap()
|
||||
.len();
|
||||
let server = MockServer::start().await;
|
||||
let config = test_config(&server);
|
||||
let auth = test_auth();
|
||||
@@ -303,9 +348,15 @@ async fn save_remote_plugin_share_updates_existing_workspace_plugin() {
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let result = save_remote_plugin_share(&config, Some(&auth), &plugin_path, Some("plugins_123"))
|
||||
.await
|
||||
.unwrap();
|
||||
let result = save_remote_plugin_share(
|
||||
&config,
|
||||
Some(&auth),
|
||||
codex_home.path(),
|
||||
&plugin_path,
|
||||
Some("plugins_123"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
@@ -318,6 +369,10 @@ async fn save_remote_plugin_share_updates_existing_workspace_plugin() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_remote_plugin_shares_fetches_created_workspace_plugins() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let local_plugin_path =
|
||||
AbsolutePathBuf::try_from(codex_home.path().join("local-plugin")).unwrap();
|
||||
write_plugin_share_local_path_mapping(codex_home.path(), "plugins_123", &local_plugin_path);
|
||||
let server = MockServer::start().await;
|
||||
let config = test_config(&server);
|
||||
let auth = test_auth();
|
||||
@@ -332,7 +387,10 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() {
|
||||
))
|
||||
.and(query_param_is_missing("pageToken"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"plugins": [remote_plugin_json("plugins_123")],
|
||||
"plugins": [remote_plugin_json_with_share_url(
|
||||
"plugins_123",
|
||||
Some("https://chatgpt.example/plugins/share/share-key-1"),
|
||||
)],
|
||||
"pagination": {
|
||||
"next_page_token": "page-2"
|
||||
},
|
||||
@@ -350,7 +408,7 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() {
|
||||
))
|
||||
.and(query_param("pageToken", "page-2"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"plugins": [remote_plugin_json("plugins_456")],
|
||||
"plugins": [remote_plugin_json_with_share_url("plugins_456", /*share_url*/ None)],
|
||||
"pagination": empty_pagination_json(),
|
||||
})))
|
||||
.expect(1)
|
||||
@@ -367,32 +425,40 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() {
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let result = list_remote_plugin_shares(&config, Some(&auth))
|
||||
let result = list_remote_plugin_shares(&config, Some(&auth), codex_home.path())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
vec![
|
||||
RemotePluginSummary {
|
||||
id: "plugins_123".to_string(),
|
||||
name: "demo-plugin".to_string(),
|
||||
installed: false,
|
||||
enabled: false,
|
||||
install_policy: PluginInstallPolicy::Available,
|
||||
auth_policy: PluginAuthPolicy::OnUse,
|
||||
availability: PluginAvailability::Available,
|
||||
interface: Some(expected_plugin_interface()),
|
||||
RemotePluginShareSummary {
|
||||
summary: RemotePluginSummary {
|
||||
id: "plugins_123".to_string(),
|
||||
name: "demo-plugin".to_string(),
|
||||
installed: false,
|
||||
enabled: false,
|
||||
install_policy: PluginInstallPolicy::Available,
|
||||
auth_policy: PluginAuthPolicy::OnUse,
|
||||
availability: PluginAvailability::Available,
|
||||
interface: Some(expected_plugin_interface()),
|
||||
},
|
||||
share_url: Some("https://chatgpt.example/plugins/share/share-key-1".to_string()),
|
||||
local_plugin_path: Some(local_plugin_path),
|
||||
},
|
||||
RemotePluginSummary {
|
||||
id: "plugins_456".to_string(),
|
||||
name: "demo-plugin".to_string(),
|
||||
installed: true,
|
||||
enabled: true,
|
||||
install_policy: PluginInstallPolicy::Available,
|
||||
auth_policy: PluginAuthPolicy::OnUse,
|
||||
availability: PluginAvailability::Available,
|
||||
interface: Some(expected_plugin_interface()),
|
||||
RemotePluginShareSummary {
|
||||
summary: RemotePluginSummary {
|
||||
id: "plugins_456".to_string(),
|
||||
name: "demo-plugin".to_string(),
|
||||
installed: true,
|
||||
enabled: true,
|
||||
install_policy: PluginInstallPolicy::Available,
|
||||
auth_policy: PluginAuthPolicy::OnUse,
|
||||
availability: PluginAvailability::Available,
|
||||
interface: Some(expected_plugin_interface()),
|
||||
},
|
||||
share_url: None,
|
||||
local_plugin_path: None,
|
||||
}
|
||||
]
|
||||
);
|
||||
@@ -400,6 +466,10 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_remote_plugin_share_deletes_workspace_plugin() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let local_plugin_path =
|
||||
AbsolutePathBuf::try_from(codex_home.path().join("local-plugin")).unwrap();
|
||||
write_plugin_share_local_path_mapping(codex_home.path(), "plugins_123", &local_plugin_path);
|
||||
let server = MockServer::start().await;
|
||||
let config = test_config(&server);
|
||||
let auth = test_auth();
|
||||
@@ -413,7 +483,11 @@ async fn delete_remote_plugin_share_deletes_workspace_plugin() {
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
delete_remote_plugin_share(&config, Some(&auth), "plugins_123")
|
||||
delete_remote_plugin_share(&config, Some(&auth), codex_home.path(), "plugins_123")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
local_paths::load_plugin_share_local_paths(codex_home.path()).unwrap(),
|
||||
BTreeMap::new()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -693,6 +693,45 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"DebugConfigLockToml": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"allow_codex_version_mismatch": {
|
||||
"description": "Allow replaying a lock generated by a different Codex version.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"export_dir": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
}
|
||||
],
|
||||
"description": "Directory where Codex writes effective session config lock files."
|
||||
},
|
||||
"load_path": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
}
|
||||
],
|
||||
"description": "Lockfile to replay as the authoritative effective config."
|
||||
},
|
||||
"save_fields_resolved_from_model_catalog": {
|
||||
"description": "Save fields resolved from the model catalog/session configuration.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"DebugToml": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"config_lockfile": {
|
||||
"$ref": "#/definitions/DebugConfigLockToml"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ExternalConfigMigrationPrompts": {
|
||||
"additionalProperties": false,
|
||||
"description": "Settings for notices we display to users via the tui and app-server clients (primarily the Codex IDE extension). NOTE: these are different from notifications - notices are warnings, NUX screens, acknowledgements, etc.",
|
||||
@@ -853,6 +892,7 @@
|
||||
"description": "Settings that govern if and what will be written to `~/.codex/history.jsonl`.",
|
||||
"properties": {
|
||||
"max_bytes": {
|
||||
"default": null,
|
||||
"description": "If set, the maximum size of the history file in bytes. The oldest entries are dropped once the file exceeds this limit.",
|
||||
"format": "uint",
|
||||
"minimum": 0.0,
|
||||
@@ -864,12 +904,10 @@
|
||||
"$ref": "#/definitions/HistoryPersistence"
|
||||
}
|
||||
],
|
||||
"default": "save-all",
|
||||
"description": "If true, history entries will not be written to disk."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"persistence"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"HistoryPersistence": {
|
||||
@@ -2528,6 +2566,11 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"status_line_use_colors": {
|
||||
"default": true,
|
||||
"description": "Color status line items with colors derived from the active syntax theme. Defaults to `true`.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"terminal_resize_reflow_max_rows": {
|
||||
"default": null,
|
||||
"description": "Trim terminal resize-reflow replay to the most recent rendered terminal rows when the transcript exceeds this cap. Omit to use Codex's terminal-specific default. Set to `0` to keep all rendered rows.",
|
||||
@@ -3624,6 +3667,7 @@
|
||||
"description": "Agent-related settings (thread limits, etc.)."
|
||||
},
|
||||
"allow_login_shell": {
|
||||
"default": true,
|
||||
"description": "Whether the model may request a login shell for shell-based tools. Default to `true`\n\nIf `true`, the model may request a login shell (`login = true`), and omitting `login` defaults to using a login shell. If `false`, the model can never use a login shell: `login = true` requests are rejected, and omitting `login` defaults to a non-login shell.",
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -3709,6 +3753,14 @@
|
||||
"description": "Compact prompt used for history compaction.",
|
||||
"type": "string"
|
||||
},
|
||||
"debug": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/DebugToml"
|
||||
}
|
||||
],
|
||||
"description": "Debugging and reproducibility settings."
|
||||
},
|
||||
"default_permissions": {
|
||||
"description": "Default permissions profile to apply. Names starting with `:` refer to built-in profiles; other names are resolved from the `[permissions]` table.",
|
||||
"type": "string"
|
||||
@@ -4055,6 +4107,7 @@
|
||||
"description": "Compatibility-only settings retained so legacy `ghost_snapshot` config still loads."
|
||||
},
|
||||
"hide_agent_reasoning": {
|
||||
"default": false,
|
||||
"description": "When set to `true`, `AgentReasoning` events will be hidden from the UI/output. Defaults to `false`.",
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -4064,7 +4117,10 @@
|
||||
"$ref": "#/definitions/History"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"default": {
|
||||
"max_bytes": null,
|
||||
"persistence": "save-all"
|
||||
},
|
||||
"description": "Settings that govern if and what will be written to `~/.codex/history.jsonl`."
|
||||
},
|
||||
"hooks": {
|
||||
@@ -4275,6 +4331,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
"project_doc_fallback_filenames": {
|
||||
"default": [],
|
||||
"description": "Ordered list of fallback filenames to look for when AGENTS.md is missing.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
@@ -4282,6 +4339,7 @@
|
||||
"type": "array"
|
||||
},
|
||||
"project_doc_max_bytes": {
|
||||
"default": 32768,
|
||||
"description": "Maximum number of bytes to include from an AGENTS.md project doc file.",
|
||||
"format": "uint",
|
||||
"minimum": 0.0,
|
||||
|
||||
@@ -5,16 +5,12 @@ use crate::agent::role::DEFAULT_ROLE_NAME;
|
||||
use crate::agent::role::resolve_role_config;
|
||||
use crate::agent::status::is_final;
|
||||
use crate::codex_thread::ThreadConfigSnapshot;
|
||||
use crate::find_archived_thread_path_by_id_str;
|
||||
use crate::find_thread_path_by_id_str;
|
||||
use crate::rollout::RolloutRecorder;
|
||||
use crate::session::emit_subagent_session_started;
|
||||
use crate::session_prefix::format_subagent_context_line;
|
||||
use crate::session_prefix::format_subagent_notification_message;
|
||||
use crate::shell_snapshot::ShellSnapshot;
|
||||
use crate::thread_manager::ResumeThreadFromRolloutOptions;
|
||||
use crate::thread_manager::ResumeThreadWithHistoryOptions;
|
||||
use crate::thread_manager::ThreadManagerState;
|
||||
use crate::thread_manager::thread_store_from_config;
|
||||
use crate::thread_rollout_truncation::truncate_rollout_to_last_n_fork_turns;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::AgentPath;
|
||||
@@ -27,6 +23,7 @@ use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::InitialHistory;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::ResumedHistory;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
@@ -34,6 +31,7 @@ use codex_protocol::protocol::TurnEnvironmentSelection;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_rollout::state_db;
|
||||
use codex_state::DirectionalThreadSpawnEdgeStatus;
|
||||
use codex_thread_store::ReadThreadParams;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::VecDeque;
|
||||
@@ -235,7 +233,6 @@ impl AgentControl {
|
||||
state
|
||||
.spawn_new_thread_with_source(
|
||||
config.clone(),
|
||||
thread_store_from_config(&config),
|
||||
self.clone(),
|
||||
session_source,
|
||||
/*persist_extended_history*/ false,
|
||||
@@ -246,15 +243,7 @@ impl AgentControl {
|
||||
)
|
||||
.await?
|
||||
}
|
||||
(None, _) => {
|
||||
state
|
||||
.spawn_new_thread(
|
||||
config.clone(),
|
||||
thread_store_from_config(&config),
|
||||
self.clone(),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
(None, _) => state.spawn_new_thread(config.clone(), self.clone()).await?,
|
||||
};
|
||||
agent_metadata.agent_id = Some(new_thread.thread_id);
|
||||
reservation.commit(agent_metadata.clone());
|
||||
@@ -377,23 +366,21 @@ impl AgentControl {
|
||||
parent_thread.codex.session.flush_rollout().await?;
|
||||
}
|
||||
|
||||
let rollout_path = parent_thread
|
||||
.as_ref()
|
||||
.and_then(|parent_thread| parent_thread.rollout_path())
|
||||
.or(find_thread_path_by_id_str(
|
||||
config.codex_home.as_path(),
|
||||
&parent_thread_id.to_string(),
|
||||
)
|
||||
.await?)
|
||||
let parent_history = state
|
||||
.read_stored_thread(ReadThreadParams {
|
||||
thread_id: parent_thread_id,
|
||||
include_archived: true,
|
||||
include_history: true,
|
||||
})
|
||||
.await?
|
||||
.history
|
||||
.ok_or_else(|| {
|
||||
CodexErr::Fatal(format!(
|
||||
"parent thread rollout unavailable for fork: {parent_thread_id}"
|
||||
"parent thread history unavailable for fork: {parent_thread_id}"
|
||||
))
|
||||
})?;
|
||||
|
||||
let mut forked_rollout_items = RolloutRecorder::get_rollout_history(&rollout_path)
|
||||
.await?
|
||||
.get_rollout_items();
|
||||
let mut forked_rollout_items = parent_history.items;
|
||||
if let SpawnAgentForkMode::LastNTurns(last_n_turns) = fork_mode {
|
||||
forked_rollout_items =
|
||||
truncate_rollout_to_last_n_fork_turns(&forked_rollout_items, *last_n_turns);
|
||||
@@ -436,7 +423,6 @@ impl AgentControl {
|
||||
state
|
||||
.fork_thread_with_source(
|
||||
config.clone(),
|
||||
thread_store_from_config(&config),
|
||||
InitialHistory::Forked(forked_rollout_items),
|
||||
self.clone(),
|
||||
session_source,
|
||||
@@ -576,24 +562,26 @@ impl AgentControl {
|
||||
let inherited_exec_policy = self
|
||||
.inherited_exec_policy_for_source(&state, Some(&session_source), &config)
|
||||
.await;
|
||||
let rollout_path =
|
||||
match find_thread_path_by_id_str(config.codex_home.as_path(), &thread_id.to_string())
|
||||
.await?
|
||||
{
|
||||
Some(rollout_path) => rollout_path,
|
||||
None => find_archived_thread_path_by_id_str(
|
||||
config.codex_home.as_path(),
|
||||
&thread_id.to_string(),
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| CodexErr::ThreadNotFound(thread_id))?,
|
||||
};
|
||||
let stored_thread = state
|
||||
.read_stored_thread(ReadThreadParams {
|
||||
thread_id,
|
||||
include_archived: true,
|
||||
include_history: true,
|
||||
})
|
||||
.await?;
|
||||
let history = stored_thread
|
||||
.history
|
||||
.ok_or_else(|| CodexErr::ThreadNotFound(thread_id))?
|
||||
.items;
|
||||
|
||||
let resumed_thread = state
|
||||
.resume_thread_from_rollout_with_source(ResumeThreadFromRolloutOptions {
|
||||
.resume_thread_with_history_with_source(ResumeThreadWithHistoryOptions {
|
||||
config: config.clone(),
|
||||
thread_store: thread_store_from_config(&config),
|
||||
rollout_path,
|
||||
initial_history: InitialHistory::Resumed(ResumedHistory {
|
||||
conversation_id: thread_id,
|
||||
history,
|
||||
rollout_path: stored_thread.rollout_path,
|
||||
}),
|
||||
agent_control: self.clone(),
|
||||
session_source,
|
||||
inherited_shell_snapshot,
|
||||
|
||||
@@ -7,7 +7,6 @@ use crate::config::Config;
|
||||
use crate::config::ConfigBuilder;
|
||||
use crate::context::ContextualUserFragment;
|
||||
use crate::context::SubagentNotification;
|
||||
use crate::thread_manager::thread_store_from_config;
|
||||
use assert_matches::assert_matches;
|
||||
use codex_features::Feature;
|
||||
use codex_login::CodexAuth;
|
||||
@@ -27,6 +26,7 @@ use codex_protocol::protocol::TurnCompleteEvent;
|
||||
use codex_protocol::protocol::TurnStartedEvent;
|
||||
use codex_thread_store::ArchiveThreadParams;
|
||||
use codex_thread_store::LocalThreadStore;
|
||||
use codex_thread_store::LocalThreadStoreConfig;
|
||||
use codex_thread_store::ThreadStore;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
@@ -109,7 +109,7 @@ impl AgentControlHarness {
|
||||
async fn start_thread(&self) -> (ThreadId, Arc<CodexThread>) {
|
||||
let new_thread = self
|
||||
.manager
|
||||
.start_thread(self.config.clone(), thread_store_from_config(&self.config))
|
||||
.start_thread(self.config.clone())
|
||||
.await
|
||||
.expect("start thread");
|
||||
(new_thread.thread_id, new_thread.thread)
|
||||
@@ -610,10 +610,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
|
||||
Some("Child subagent guidance.".to_string());
|
||||
let new_thread = harness
|
||||
.manager
|
||||
.start_thread(
|
||||
parent_config.clone(),
|
||||
thread_store_from_config(&parent_config),
|
||||
)
|
||||
.start_thread(parent_config.clone())
|
||||
.await
|
||||
.expect("start parent thread");
|
||||
let parent_thread_id = new_thread.thread_id;
|
||||
@@ -956,7 +953,7 @@ async fn spawn_agent_respects_max_threads_limit() {
|
||||
let control = manager.agent_control();
|
||||
|
||||
let _ = manager
|
||||
.start_thread(config.clone(), thread_store_from_config(&config))
|
||||
.start_thread(config.clone())
|
||||
.await
|
||||
.expect("start thread");
|
||||
|
||||
@@ -1313,10 +1310,7 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() {
|
||||
let _ = tester_config.features.enable(Feature::MultiAgentV2);
|
||||
let tester_thread_id = harness
|
||||
.manager
|
||||
.start_thread(
|
||||
tester_config.clone(),
|
||||
thread_store_from_config(&tester_config),
|
||||
)
|
||||
.start_thread(tester_config.clone())
|
||||
.await
|
||||
.expect("tester thread should start")
|
||||
.thread_id;
|
||||
@@ -1701,7 +1695,7 @@ async fn resume_agent_from_rollout_reads_archived_rollout_path() {
|
||||
.shutdown_live_agent(child_thread_id)
|
||||
.await
|
||||
.expect("child shutdown should succeed");
|
||||
let store = LocalThreadStore::new(codex_rollout::RolloutConfig::from_view(&harness.config));
|
||||
let store = LocalThreadStore::new(LocalThreadStoreConfig::from_config(&harness.config));
|
||||
store
|
||||
.archive_thread(ArchiveThreadParams {
|
||||
thread_id: child_thread_id,
|
||||
|
||||
@@ -25,6 +25,7 @@ use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::SessionConfiguredEvent;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::Submission;
|
||||
use codex_protocol::protocol::ThreadMemoryMode;
|
||||
@@ -93,6 +94,7 @@ pub struct CodexThreadTurnContextOverrides {
|
||||
pub struct CodexThread {
|
||||
pub(crate) codex: Codex,
|
||||
pub(crate) session_source: SessionSource,
|
||||
session_configured: SessionConfiguredEvent,
|
||||
rollout_path: Option<PathBuf>,
|
||||
out_of_band_elicitation_count: Mutex<u64>,
|
||||
_watch_registration: WatchRegistration,
|
||||
@@ -103,6 +105,7 @@ pub struct CodexThread {
|
||||
impl CodexThread {
|
||||
pub(crate) fn new(
|
||||
codex: Codex,
|
||||
session_configured: SessionConfiguredEvent,
|
||||
rollout_path: Option<PathBuf>,
|
||||
session_source: SessionSource,
|
||||
watch_registration: WatchRegistration,
|
||||
@@ -110,6 +113,7 @@ impl CodexThread {
|
||||
Self {
|
||||
codex,
|
||||
session_source,
|
||||
session_configured,
|
||||
rollout_path,
|
||||
out_of_band_elicitation_count: Mutex::new(0),
|
||||
_watch_registration: watch_registration,
|
||||
@@ -377,6 +381,14 @@ impl CodexThread {
|
||||
self.rollout_path.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn session_configured(&self) -> SessionConfiguredEvent {
|
||||
self.session_configured.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn is_running(&self) -> bool {
|
||||
!self.codex.tx_sub.is_closed()
|
||||
}
|
||||
|
||||
pub async fn guardian_trunk_rollout_path(&self) -> Option<PathBuf> {
|
||||
self.codex
|
||||
.session
|
||||
|
||||
@@ -1474,6 +1474,30 @@ async fn cli_override_model_instructions_file_sets_base_instructions() -> std::i
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inline_instructions_set_base_instructions() -> std::io::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
let codex_home = tmp.path().join("home");
|
||||
tokio::fs::create_dir_all(&codex_home).await?;
|
||||
tokio::fs::write(
|
||||
codex_home.join(CONFIG_TOML_FILE),
|
||||
r#"instructions = "snapshot instructions""#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home)
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
config.base_instructions.as_deref(),
|
||||
Some("snapshot instructions")
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn project_layer_is_added_when_dot_codex_exists_without_config_toml() -> std::io::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
|
||||
@@ -552,6 +552,7 @@ fn config_toml_deserializes_model_availability_nux() {
|
||||
vim_mode_default: false,
|
||||
alternate_screen: AltScreenMode::default(),
|
||||
status_line: None,
|
||||
status_line_use_colors: true,
|
||||
terminal_title: None,
|
||||
theme: None,
|
||||
keymap: TuiKeymap::default(),
|
||||
@@ -566,6 +567,37 @@ fn config_toml_deserializes_model_availability_nux() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_toml_status_line_use_colors_defaults_to_enabled() {
|
||||
let toml = r#"
|
||||
[tui]
|
||||
"#;
|
||||
let cfg: ConfigToml =
|
||||
toml::from_str(toml).expect("TOML deserialization should succeed for TUI config");
|
||||
|
||||
assert!(
|
||||
cfg.tui
|
||||
.expect("tui config should deserialize")
|
||||
.status_line_use_colors
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_toml_deserializes_status_line_use_colors_disabled() {
|
||||
let toml = r#"
|
||||
[tui]
|
||||
status_line_use_colors = false
|
||||
"#;
|
||||
let cfg: ConfigToml =
|
||||
toml::from_str(toml).expect("TOML deserialization should succeed for TUI config");
|
||||
|
||||
assert!(
|
||||
!cfg.tui
|
||||
.expect("tui config should deserialize")
|
||||
.status_line_use_colors
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_toml_deserializes_terminal_resize_reflow_config() {
|
||||
let toml = r#"
|
||||
@@ -2095,6 +2127,7 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() {
|
||||
vim_mode_default: false,
|
||||
alternate_screen: AltScreenMode::Auto,
|
||||
status_line: None,
|
||||
status_line_use_colors: true,
|
||||
terminal_title: None,
|
||||
theme: None,
|
||||
keymap: TuiKeymap::default(),
|
||||
@@ -6354,6 +6387,10 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
|
||||
codex_home: fixture.codex_home(),
|
||||
sqlite_home: fixture.codex_home().to_path_buf(),
|
||||
log_dir: fixture.codex_home().join("log").to_path_buf(),
|
||||
config_lock_export_dir: None,
|
||||
config_lock_allow_codex_version_mismatch: false,
|
||||
config_lock_save_fields_resolved_from_model_catalog: true,
|
||||
config_lock_toml: None,
|
||||
config_layer_stack: Default::default(),
|
||||
startup_warnings: Vec::new(),
|
||||
history: History::default(),
|
||||
@@ -6421,6 +6458,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
|
||||
tool_suggest: ToolSuggestConfig::default(),
|
||||
tui_alternate_screen: AltScreenMode::Auto,
|
||||
tui_status_line: None,
|
||||
tui_status_line_use_colors: true,
|
||||
tui_terminal_title: None,
|
||||
tui_theme: None,
|
||||
otel: OtelConfig::default(),
|
||||
@@ -6551,6 +6589,10 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
|
||||
codex_home: fixture.codex_home(),
|
||||
sqlite_home: fixture.codex_home().to_path_buf(),
|
||||
log_dir: fixture.codex_home().join("log").to_path_buf(),
|
||||
config_lock_export_dir: None,
|
||||
config_lock_allow_codex_version_mismatch: false,
|
||||
config_lock_save_fields_resolved_from_model_catalog: true,
|
||||
config_lock_toml: None,
|
||||
config_layer_stack: Default::default(),
|
||||
startup_warnings: Vec::new(),
|
||||
history: History::default(),
|
||||
@@ -6618,6 +6660,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
|
||||
tool_suggest: ToolSuggestConfig::default(),
|
||||
tui_alternate_screen: AltScreenMode::Auto,
|
||||
tui_status_line: None,
|
||||
tui_status_line_use_colors: true,
|
||||
tui_terminal_title: None,
|
||||
tui_theme: None,
|
||||
otel: OtelConfig::default(),
|
||||
@@ -6702,6 +6745,10 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
|
||||
codex_home: fixture.codex_home(),
|
||||
sqlite_home: fixture.codex_home().to_path_buf(),
|
||||
log_dir: fixture.codex_home().join("log").to_path_buf(),
|
||||
config_lock_export_dir: None,
|
||||
config_lock_allow_codex_version_mismatch: false,
|
||||
config_lock_save_fields_resolved_from_model_catalog: true,
|
||||
config_lock_toml: None,
|
||||
config_layer_stack: Default::default(),
|
||||
startup_warnings: Vec::new(),
|
||||
history: History::default(),
|
||||
@@ -6769,6 +6816,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
|
||||
tool_suggest: ToolSuggestConfig::default(),
|
||||
tui_alternate_screen: AltScreenMode::Auto,
|
||||
tui_status_line: None,
|
||||
tui_status_line_use_colors: true,
|
||||
tui_terminal_title: None,
|
||||
tui_theme: None,
|
||||
otel: OtelConfig::default(),
|
||||
@@ -6838,6 +6886,10 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
|
||||
codex_home: fixture.codex_home(),
|
||||
sqlite_home: fixture.codex_home().to_path_buf(),
|
||||
log_dir: fixture.codex_home().join("log").to_path_buf(),
|
||||
config_lock_export_dir: None,
|
||||
config_lock_allow_codex_version_mismatch: false,
|
||||
config_lock_save_fields_resolved_from_model_catalog: true,
|
||||
config_lock_toml: None,
|
||||
config_layer_stack: Default::default(),
|
||||
startup_warnings: Vec::new(),
|
||||
history: History::default(),
|
||||
@@ -6905,6 +6957,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
|
||||
tool_suggest: ToolSuggestConfig::default(),
|
||||
tui_alternate_screen: AltScreenMode::Auto,
|
||||
tui_status_line: None,
|
||||
tui_status_line_use_colors: true,
|
||||
tui_terminal_title: None,
|
||||
tui_theme: None,
|
||||
otel: OtelConfig::default(),
|
||||
@@ -7967,6 +8020,77 @@ async fn browser_feature_requirements_are_valid() -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn debug_config_lockfile_export_settings_load_from_nested_table() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
std::fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
r#"[debug.config_lockfile]
|
||||
export_dir = "locks"
|
||||
allow_codex_version_mismatch = true
|
||||
save_fields_resolved_from_model_catalog = false
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
config.config_lock_export_dir,
|
||||
Some(AbsolutePathBuf::resolve_path_against_base(
|
||||
"locks",
|
||||
codex_home.path()
|
||||
))
|
||||
);
|
||||
assert!(config.config_lock_allow_codex_version_mismatch);
|
||||
assert!(!config.config_lock_save_fields_resolved_from_model_catalog);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn debug_config_lockfile_load_path_loads_lock_from_nested_table() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let lock_path = codex_home.path().join("session.config.lock.toml");
|
||||
std::fs::write(
|
||||
&lock_path,
|
||||
format!(
|
||||
r#"version = {}
|
||||
codex_version = "older-version"
|
||||
|
||||
[config]
|
||||
"#,
|
||||
crate::config_lock::CONFIG_LOCK_VERSION
|
||||
),
|
||||
)?;
|
||||
std::fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
format!(
|
||||
r#"[debug.config_lockfile]
|
||||
load_path = '{}'
|
||||
allow_codex_version_mismatch = true
|
||||
save_fields_resolved_from_model_catalog = false
|
||||
"#,
|
||||
lock_path.display()
|
||||
),
|
||||
)?;
|
||||
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
assert!(config.config_lock_toml.is_some());
|
||||
assert!(config.config_lock_allow_codex_version_mismatch);
|
||||
assert!(!config.config_lock_save_fields_resolved_from_model_catalog);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_feature_config_is_normalized_by_requirements() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
@@ -104,6 +104,14 @@ pub fn status_line_items_edit(items: &[String]) -> ConfigEdit {
|
||||
}
|
||||
}
|
||||
|
||||
/// Produces a config edit that sets `[tui].status_line_use_colors`.
|
||||
pub fn status_line_use_colors_edit(enabled: bool) -> ConfigEdit {
|
||||
ConfigEdit::SetPath {
|
||||
segments: vec!["tui".to_string(), "status_line_use_colors".to_string()],
|
||||
value: value(enabled),
|
||||
}
|
||||
}
|
||||
|
||||
/// Produces a config edit that sets `[tui].terminal_title` to an explicit ordered list.
|
||||
///
|
||||
/// The array is written even when it is empty so "disabled title updates" stays
|
||||
|
||||
@@ -22,7 +22,9 @@ use codex_config::ResidencyRequirement;
|
||||
use codex_config::SandboxModeRequirement;
|
||||
use codex_config::Sourced;
|
||||
use codex_config::ThreadConfigLoader;
|
||||
use codex_config::config_toml::ConfigLockfileToml;
|
||||
use codex_config::config_toml::ConfigToml;
|
||||
use codex_config::config_toml::DEFAULT_PROJECT_DOC_MAX_BYTES;
|
||||
use codex_config::config_toml::ProjectConfig;
|
||||
use codex_config::config_toml::RealtimeAudioConfig;
|
||||
use codex_config::config_toml::RealtimeConfig;
|
||||
@@ -99,6 +101,7 @@ use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_absolute_path::AbsolutePathBufGuard;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
@@ -114,6 +117,9 @@ use crate::config::permissions::default_builtin_permission_profile_name;
|
||||
use crate::config::permissions::get_readable_roots_required_for_codex_runtime;
|
||||
use crate::config::permissions::network_proxy_config_for_profile_selection;
|
||||
use crate::config::permissions::validate_user_permission_profile_names;
|
||||
use crate::config_lock::config_without_lock_controls;
|
||||
use crate::config_lock::lock_layer_from_config;
|
||||
use crate::config_lock::read_config_lock_from_path;
|
||||
use codex_network_proxy::NetworkProxyConfig;
|
||||
use toml::Value as TomlValue;
|
||||
use toml_edit::DocumentMut;
|
||||
@@ -162,7 +168,7 @@ impl Default for GhostSnapshotConfig {
|
||||
/// Maximum number of bytes of the documentation that will be embedded. Larger
|
||||
/// files are *silently truncated* to this size so we do not take up too much of
|
||||
/// the context window.
|
||||
pub(crate) const AGENTS_MD_MAX_BYTES: usize = 32 * 1024; // 32 KiB
|
||||
pub(crate) const AGENTS_MD_MAX_BYTES: usize = DEFAULT_PROJECT_DOC_MAX_BYTES; // 32 KiB
|
||||
pub(crate) const DEFAULT_AGENT_MAX_THREADS: Option<usize> = Some(6);
|
||||
pub(crate) const DEFAULT_MULTI_AGENT_V2_MAX_CONCURRENT_THREADS_PER_SESSION: usize = 4;
|
||||
pub(crate) const DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS: i64 = 10_000;
|
||||
@@ -524,6 +530,9 @@ pub struct Config {
|
||||
/// When unset, the TUI defaults to: `model-with-reasoning` and `current-dir`.
|
||||
pub tui_status_line: Option<Vec<String>>,
|
||||
|
||||
/// Whether to color status line items with colors from the active syntax theme.
|
||||
pub tui_status_line_use_colors: bool,
|
||||
|
||||
/// Ordered list of terminal title item identifiers for the TUI.
|
||||
///
|
||||
/// When unset, the TUI defaults to: `activity` and `project`.
|
||||
@@ -620,6 +629,20 @@ pub struct Config {
|
||||
/// Directory where Codex writes log files (defaults to `$CODEX_HOME/log`).
|
||||
pub log_dir: PathBuf,
|
||||
|
||||
/// Directory where Codex writes effective session config lock files.
|
||||
pub config_lock_export_dir: Option<AbsolutePathBuf>,
|
||||
|
||||
/// Whether config lock replay ignores Codex version drift between the
|
||||
/// lock metadata and the regenerated lock.
|
||||
pub config_lock_allow_codex_version_mismatch: bool,
|
||||
|
||||
/// Whether config lock creation saves values resolved from the model
|
||||
/// catalog/session configuration.
|
||||
pub config_lock_save_fields_resolved_from_model_catalog: bool,
|
||||
|
||||
/// Effective config lock used for strict replay validation.
|
||||
pub config_lock_toml: Option<Arc<ConfigLockfileToml>>,
|
||||
|
||||
/// Settings that govern if and what will be written to `~/.codex/history.jsonl`.
|
||||
pub history: History,
|
||||
|
||||
@@ -789,7 +812,7 @@ pub struct Config {
|
||||
pub otel: codex_config::types::OtelConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct MultiAgentV2Config {
|
||||
pub max_concurrent_threads_per_session: usize,
|
||||
pub min_wait_timeout_ms: i64,
|
||||
@@ -958,6 +981,42 @@ impl ConfigBuilder {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, err));
|
||||
}
|
||||
};
|
||||
let config_lock_settings = config_toml
|
||||
.debug
|
||||
.as_ref()
|
||||
.and_then(|debug| debug.config_lockfile.as_ref());
|
||||
if let Some(config_lock_load_path) =
|
||||
config_lock_settings.and_then(|config_lock| config_lock.load_path.as_ref())
|
||||
{
|
||||
let allow_codex_version_mismatch = config_lock_settings
|
||||
.and_then(|config_lock| config_lock.allow_codex_version_mismatch)
|
||||
.unwrap_or(false);
|
||||
let save_fields_resolved_from_model_catalog = config_lock_settings
|
||||
.and_then(|config_lock| config_lock.save_fields_resolved_from_model_catalog)
|
||||
.unwrap_or(true);
|
||||
let lockfile_toml = read_config_lock_from_path(config_lock_load_path).await?;
|
||||
let expected_lock_config = lockfile_toml.clone();
|
||||
let lock_layer = lock_layer_from_config(config_lock_load_path, &lockfile_toml)?;
|
||||
let lock_config_toml = config_without_lock_controls(&lockfile_toml.config);
|
||||
let lock_config_layer_stack = ConfigLayerStack::new(
|
||||
vec![lock_layer],
|
||||
config_layer_stack.requirements().clone(),
|
||||
config_layer_stack.requirements_toml().clone(),
|
||||
)?;
|
||||
let mut config = Config::load_config_with_layer_stack(
|
||||
LOCAL_FS.as_ref(),
|
||||
lock_config_toml,
|
||||
harness_overrides,
|
||||
codex_home,
|
||||
lock_config_layer_stack,
|
||||
)
|
||||
.await?;
|
||||
config.config_lock_toml = Some(Arc::new(expected_lock_config));
|
||||
config.config_lock_allow_codex_version_mismatch = allow_codex_version_mismatch;
|
||||
config.config_lock_save_fields_resolved_from_model_catalog =
|
||||
save_fields_resolved_from_model_catalog;
|
||||
return Ok(config);
|
||||
}
|
||||
Config::load_config_with_layer_stack(
|
||||
LOCAL_FS.as_ref(),
|
||||
config_toml,
|
||||
@@ -2638,7 +2697,9 @@ impl Config {
|
||||
"model instructions file",
|
||||
)
|
||||
.await?;
|
||||
let base_instructions = base_instructions.or(file_base_instructions);
|
||||
let base_instructions = base_instructions
|
||||
.or(file_base_instructions)
|
||||
.or(cfg.instructions.clone());
|
||||
let developer_instructions = developer_instructions.or(cfg.developer_instructions);
|
||||
let include_permissions_instructions = config_profile
|
||||
.include_permissions_instructions
|
||||
@@ -2910,6 +2971,24 @@ impl Config {
|
||||
codex_home,
|
||||
sqlite_home,
|
||||
log_dir,
|
||||
config_lock_export_dir: cfg
|
||||
.debug
|
||||
.as_ref()
|
||||
.and_then(|debug| debug.config_lockfile.as_ref())
|
||||
.and_then(|config_lock| config_lock.export_dir.clone()),
|
||||
config_lock_allow_codex_version_mismatch: cfg
|
||||
.debug
|
||||
.as_ref()
|
||||
.and_then(|debug| debug.config_lockfile.as_ref())
|
||||
.and_then(|config_lock| config_lock.allow_codex_version_mismatch)
|
||||
.unwrap_or(false),
|
||||
config_lock_save_fields_resolved_from_model_catalog: cfg
|
||||
.debug
|
||||
.as_ref()
|
||||
.and_then(|debug| debug.config_lockfile.as_ref())
|
||||
.and_then(|config_lock| config_lock.save_fields_resolved_from_model_catalog)
|
||||
.unwrap_or(true),
|
||||
config_lock_toml: None,
|
||||
config_layer_stack,
|
||||
history,
|
||||
ephemeral: ephemeral.unwrap_or_default(),
|
||||
@@ -3022,6 +3101,11 @@ impl Config {
|
||||
.map(|t| t.alternate_screen)
|
||||
.unwrap_or_default(),
|
||||
tui_status_line: cfg.tui.as_ref().and_then(|t| t.status_line.clone()),
|
||||
tui_status_line_use_colors: cfg
|
||||
.tui
|
||||
.as_ref()
|
||||
.map(|t| t.status_line_use_colors)
|
||||
.unwrap_or(true),
|
||||
tui_terminal_title: cfg.tui.as_ref().and_then(|t| t.terminal_title.clone()),
|
||||
tui_theme: cfg.tui.as_ref().and_then(|t| t.theme.clone()),
|
||||
terminal_resize_reflow,
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
use std::io;
|
||||
|
||||
use codex_config::ConfigLayerEntry;
|
||||
use codex_config::ConfigLayerSource;
|
||||
use codex_config::config_toml::ConfigLockfileToml;
|
||||
use codex_config::config_toml::ConfigToml;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use similar::TextDiff;
|
||||
|
||||
pub(crate) const CONFIG_LOCK_VERSION: u32 = 1;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct ConfigLockReplayOptions {
|
||||
pub allow_codex_version_mismatch: bool,
|
||||
}
|
||||
|
||||
pub(crate) async fn read_config_lock_from_path(
|
||||
path: &AbsolutePathBuf,
|
||||
) -> io::Result<ConfigLockfileToml> {
|
||||
let contents = tokio::fs::read_to_string(path).await.map_err(|err| {
|
||||
config_lock_error(format!(
|
||||
"failed to read config lock file {}: {err}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
let lockfile: ConfigLockfileToml = toml::from_str(&contents).map_err(|err| {
|
||||
config_lock_error(format!(
|
||||
"failed to parse config lock file {}: {err}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
validate_config_lock_metadata_shape(&lockfile)?;
|
||||
Ok(lockfile)
|
||||
}
|
||||
|
||||
pub(crate) fn config_lockfile(config: ConfigToml) -> ConfigLockfileToml {
|
||||
ConfigLockfileToml {
|
||||
version: CONFIG_LOCK_VERSION,
|
||||
codex_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_config_lock_replay(
|
||||
expected_lock: &ConfigLockfileToml,
|
||||
actual_lock: &ConfigLockfileToml,
|
||||
options: ConfigLockReplayOptions,
|
||||
) -> io::Result<()> {
|
||||
validate_config_lock_metadata_shape(expected_lock)?;
|
||||
validate_config_lock_metadata_shape(actual_lock)?;
|
||||
|
||||
if !options.allow_codex_version_mismatch
|
||||
&& expected_lock.codex_version != actual_lock.codex_version
|
||||
{
|
||||
return Err(config_lock_error(format!(
|
||||
"config lock Codex version mismatch: lock was generated by {}, current version is {}; set debug.config_lockfile.allow_codex_version_mismatch=true to ignore this",
|
||||
expected_lock.codex_version, actual_lock.codex_version
|
||||
)));
|
||||
}
|
||||
|
||||
let expected_lock = config_lock_for_comparison(expected_lock, options);
|
||||
let actual_lock = config_lock_for_comparison(actual_lock, options);
|
||||
if expected_lock != actual_lock {
|
||||
let diff = compact_diff("config", &expected_lock, &actual_lock)
|
||||
.unwrap_or_else(|err| format!("failed to build config lock diff: {err}"));
|
||||
return Err(config_lock_error(format!(
|
||||
"replayed effective config does not match config lock: {diff}"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn lock_layer_from_config(
|
||||
lock_path: &AbsolutePathBuf,
|
||||
lockfile: &ConfigLockfileToml,
|
||||
) -> io::Result<ConfigLayerEntry> {
|
||||
let value = toml_value(
|
||||
&config_without_lock_controls(&lockfile.config),
|
||||
"config lock",
|
||||
)?;
|
||||
Ok(ConfigLayerEntry::new(
|
||||
ConfigLayerSource::User {
|
||||
file: lock_path.clone(),
|
||||
},
|
||||
value,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn config_without_lock_controls(config: &ConfigToml) -> ConfigToml {
|
||||
let mut config = config.clone();
|
||||
clear_config_lock_debug_controls(&mut config);
|
||||
config
|
||||
}
|
||||
|
||||
pub(crate) fn clear_config_lock_debug_controls(config: &mut ConfigToml) {
|
||||
if let Some(debug) = config.debug.as_mut() {
|
||||
debug.config_lockfile = None;
|
||||
}
|
||||
if config
|
||||
.debug
|
||||
.as_ref()
|
||||
.is_some_and(|debug| debug.config_lockfile.is_none())
|
||||
{
|
||||
config.debug = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_config_lock_metadata_shape(lock: &ConfigLockfileToml) -> io::Result<()> {
|
||||
if lock.version != CONFIG_LOCK_VERSION {
|
||||
return Err(config_lock_error(format!(
|
||||
"unsupported config lock version {}; expected {CONFIG_LOCK_VERSION}",
|
||||
lock.version
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn config_lock_for_comparison(
|
||||
lockfile: &ConfigLockfileToml,
|
||||
options: ConfigLockReplayOptions,
|
||||
) -> ConfigLockfileToml {
|
||||
let mut lockfile = lockfile.clone();
|
||||
clear_config_lock_debug_controls(&mut lockfile.config);
|
||||
if options.allow_codex_version_mismatch {
|
||||
lockfile.codex_version.clear();
|
||||
}
|
||||
lockfile
|
||||
}
|
||||
|
||||
fn config_lock_error(message: impl Into<String>) -> io::Error {
|
||||
io::Error::other(message.into())
|
||||
}
|
||||
|
||||
fn compact_diff<T: Serialize>(root: &str, expected: &T, actual: &T) -> io::Result<String> {
|
||||
let expected = toml::to_string_pretty(expected).map_err(|err| {
|
||||
config_lock_error(format!(
|
||||
"failed to serialize expected {root} lock TOML: {err}"
|
||||
))
|
||||
})?;
|
||||
let actual = toml::to_string_pretty(actual).map_err(|err| {
|
||||
config_lock_error(format!(
|
||||
"failed to serialize actual {root} lock TOML: {err}"
|
||||
))
|
||||
})?;
|
||||
Ok(TextDiff::from_lines(&expected, &actual)
|
||||
.unified_diff()
|
||||
.context_radius(2)
|
||||
.header("expected", "actual")
|
||||
.to_string())
|
||||
}
|
||||
|
||||
fn toml_value<T: Serialize>(value: &T, label: &str) -> io::Result<toml::Value> {
|
||||
toml::Value::try_from(value)
|
||||
.map_err(|err| config_lock_error(format!("failed to serialize {label}: {err}")))
|
||||
}
|
||||
|
||||
pub(crate) fn toml_round_trip<T>(value: &impl Serialize, label: &'static str) -> io::Result<T>
|
||||
where
|
||||
T: DeserializeOwned + Serialize,
|
||||
{
|
||||
let value = toml_value(value, label)?;
|
||||
let toml = value.clone().try_into().map_err(|err| {
|
||||
config_lock_error(format!("failed to convert {label} to TOML shape: {err}"))
|
||||
})?;
|
||||
let represented_value = toml_value(&toml, label)?;
|
||||
if represented_value != value {
|
||||
return Err(config_lock_error(format!(
|
||||
"resolved {label} cannot be fully represented as TOML"
|
||||
)));
|
||||
}
|
||||
Ok(toml)
|
||||
}
|
||||
@@ -29,8 +29,6 @@ use codex_utils_template::Template;
|
||||
use futures::future::BoxFuture;
|
||||
use std::sync::Arc;
|
||||
use std::sync::LazyLock;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
use tokio::sync::Mutex;
|
||||
@@ -90,7 +88,6 @@ pub(crate) enum GoalRuntimeEvent<'a> {
|
||||
TurnFinished {
|
||||
turn_context: &'a TurnContext,
|
||||
turn_completed: bool,
|
||||
tool_calls: u64,
|
||||
},
|
||||
MaybeContinueIfIdle,
|
||||
TaskAborted {
|
||||
@@ -112,7 +109,6 @@ pub(crate) struct GoalRuntimeState {
|
||||
accounting: Mutex<GoalAccountingSnapshot>,
|
||||
continuation_turn_id: Mutex<Option<String>>,
|
||||
pub(crate) continuation_lock: Semaphore,
|
||||
pub(crate) continuation_suppressed: AtomicBool,
|
||||
}
|
||||
|
||||
struct GoalContinuationCandidate {
|
||||
@@ -129,7 +125,6 @@ impl GoalRuntimeState {
|
||||
accounting: Mutex::new(GoalAccountingSnapshot::new()),
|
||||
continuation_turn_id: Mutex::new(None),
|
||||
continuation_lock: Semaphore::new(/*permits*/ 1),
|
||||
continuation_suppressed: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -277,8 +272,8 @@ impl Session {
|
||||
/// suppresses that steering, external mutations account best-effort before
|
||||
/// changing state, interrupts pause active goals, resumes reactivate paused
|
||||
/// goals, explicit maybe-continue events start idle goal continuation turns,
|
||||
/// and no-tool continuation turns suppress the next automatic continuation
|
||||
/// until user/tool/external activity resets it.
|
||||
/// and continuation turns with no counted autonomous activity suppress the
|
||||
/// next automatic continuation until user/tool/external activity resets it.
|
||||
pub(crate) fn goal_runtime_apply<'a>(
|
||||
self: &'a Arc<Self>,
|
||||
event: GoalRuntimeEvent<'a>,
|
||||
@@ -296,7 +291,6 @@ impl Session {
|
||||
turn_context,
|
||||
tool_name,
|
||||
} => Box::pin(async move {
|
||||
self.reset_thread_goal_continuation_suppression();
|
||||
if tool_name != codex_tools::UPDATE_GOAL_TOOL_NAME {
|
||||
self.account_thread_goal_progress(turn_context, BudgetLimitSteering::Allowed)
|
||||
.await?;
|
||||
@@ -304,7 +298,6 @@ impl Session {
|
||||
Ok(())
|
||||
}),
|
||||
GoalRuntimeEvent::ToolCompletedGoal { turn_context } => Box::pin(async move {
|
||||
self.reset_thread_goal_continuation_suppression();
|
||||
self.account_thread_goal_progress(turn_context, BudgetLimitSteering::Suppressed)
|
||||
.await?;
|
||||
Ok(())
|
||||
@@ -312,9 +305,8 @@ impl Session {
|
||||
GoalRuntimeEvent::TurnFinished {
|
||||
turn_context,
|
||||
turn_completed,
|
||||
tool_calls,
|
||||
} => Box::pin(async move {
|
||||
self.finish_thread_goal_turn(turn_context, turn_completed, tool_calls)
|
||||
self.finish_thread_goal_turn(turn_context, turn_completed)
|
||||
.await;
|
||||
Ok(())
|
||||
}),
|
||||
@@ -331,7 +323,6 @@ impl Session {
|
||||
Ok(())
|
||||
}),
|
||||
GoalRuntimeEvent::ExternalMutationStarting => Box::pin(async move {
|
||||
self.reset_thread_goal_continuation_suppression();
|
||||
if let Err(err) = self.account_thread_goal_before_external_mutation().await {
|
||||
tracing::warn!(
|
||||
"failed to account thread goal progress before external mutation: {err}"
|
||||
@@ -463,7 +454,6 @@ impl Session {
|
||||
let goal_status = goal.status;
|
||||
let goal_id = goal.goal_id.clone();
|
||||
let goal = protocol_goal_from_state(goal);
|
||||
self.reset_thread_goal_continuation_suppression();
|
||||
*self.goal_runtime.budget_limit_reported_goal_id.lock().await = None;
|
||||
let newly_active_goal = goal_status == codex_state::ThreadGoalStatus::Active
|
||||
&& (replacing_goal
|
||||
@@ -532,7 +522,6 @@ impl Session {
|
||||
|
||||
let goal_id = goal.goal_id.clone();
|
||||
let goal = protocol_goal_from_state(goal);
|
||||
self.reset_thread_goal_continuation_suppression();
|
||||
*self.goal_runtime.budget_limit_reported_goal_id.lock().await = None;
|
||||
|
||||
let current_token_usage = self.total_token_usage().await.unwrap_or_default();
|
||||
@@ -561,7 +550,6 @@ impl Session {
|
||||
) {
|
||||
match status {
|
||||
codex_state::ThreadGoalStatus::Active => {
|
||||
self.reset_thread_goal_continuation_suppression();
|
||||
match self.state_db_for_thread_goals().await {
|
||||
Ok(Some(state_db)) => {
|
||||
match state_db.get_thread_goal(self.conversation_id).await {
|
||||
@@ -608,7 +596,6 @@ impl Session {
|
||||
}
|
||||
|
||||
async fn clear_stopped_thread_goal_runtime_state(&self) {
|
||||
self.reset_thread_goal_continuation_suppression();
|
||||
*self.goal_runtime.budget_limit_reported_goal_id.lock().await = None;
|
||||
let mut accounting = self.goal_runtime.accounting.lock().await;
|
||||
if let Some(turn) = accounting.turn.as_mut() {
|
||||
@@ -663,16 +650,6 @@ impl Session {
|
||||
turn_context: &TurnContext,
|
||||
token_usage: TokenUsage,
|
||||
) {
|
||||
if self
|
||||
.goal_runtime
|
||||
.continuation_turn_id
|
||||
.lock()
|
||||
.await
|
||||
.as_ref()
|
||||
.is_none_or(|turn_id| turn_id != &turn_context.sub_id)
|
||||
{
|
||||
self.reset_thread_goal_continuation_suppression();
|
||||
}
|
||||
self.goal_runtime.accounting.lock().await.turn = Some(GoalTurnAccountingSnapshot::new(
|
||||
turn_context.sub_id.clone(),
|
||||
token_usage,
|
||||
@@ -723,12 +700,6 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
fn reset_thread_goal_continuation_suppression(&self) {
|
||||
self.goal_runtime
|
||||
.continuation_suppressed
|
||||
.store(false, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
async fn mark_thread_goal_continuation_turn_started(&self, turn_id: String) {
|
||||
*self.goal_runtime.continuation_turn_id.lock().await = Some(turn_id);
|
||||
}
|
||||
@@ -757,7 +728,6 @@ impl Session {
|
||||
self: &Arc<Self>,
|
||||
turn_context: &TurnContext,
|
||||
turn_completed: bool,
|
||||
turn_tool_calls: u64,
|
||||
) {
|
||||
if turn_completed
|
||||
&& let Err(err) = self
|
||||
@@ -767,15 +737,8 @@ impl Session {
|
||||
tracing::warn!("failed to account thread goal progress at turn end: {err}");
|
||||
}
|
||||
|
||||
if self
|
||||
.take_thread_goal_continuation_turn(&turn_context.sub_id)
|
||||
.await
|
||||
&& turn_tool_calls == 0
|
||||
{
|
||||
self.goal_runtime
|
||||
.continuation_suppressed
|
||||
.store(true, Ordering::SeqCst);
|
||||
}
|
||||
self.take_thread_goal_continuation_turn(&turn_context.sub_id)
|
||||
.await;
|
||||
if turn_completed {
|
||||
let mut accounting = self.goal_runtime.accounting.lock().await;
|
||||
if accounting
|
||||
@@ -1126,7 +1089,6 @@ impl Session {
|
||||
};
|
||||
let goal_id = goal.goal_id.clone();
|
||||
let goal = protocol_goal_from_state(goal);
|
||||
self.reset_thread_goal_continuation_suppression();
|
||||
*self.goal_runtime.budget_limit_reported_goal_id.lock().await = None;
|
||||
let active_turn_id = self
|
||||
.active_turn_context()
|
||||
@@ -1255,16 +1217,6 @@ impl Session {
|
||||
);
|
||||
return None;
|
||||
}
|
||||
if self
|
||||
.goal_runtime
|
||||
.continuation_suppressed
|
||||
.load(Ordering::SeqCst)
|
||||
{
|
||||
tracing::debug!(
|
||||
"skipping active goal continuation because the last continuation made no tool calls"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let state_db = match self.state_db_for_thread_goals().await {
|
||||
Ok(Some(state_db)) => state_db,
|
||||
Ok(None) => {
|
||||
@@ -1578,7 +1530,7 @@ mod tests {
|
||||
assert!(prompt.contains("<untrusted_objective>\nfinish the stack\n</untrusted_objective>"));
|
||||
assert!(prompt.contains("Token budget: 10000"));
|
||||
assert!(prompt.contains("call update_goal with status \"complete\""));
|
||||
assert!(prompt.contains(
|
||||
assert!(!prompt.contains(
|
||||
"explain the blocker or next required input to the user and wait for new input"
|
||||
));
|
||||
assert!(!prompt.contains("budgetLimited"));
|
||||
|
||||
@@ -17,6 +17,7 @@ pub(crate) mod session;
|
||||
pub use session::SteerInputError;
|
||||
mod codex_thread;
|
||||
mod compact_remote;
|
||||
mod config_lock;
|
||||
pub use codex_thread::CodexThread;
|
||||
pub use codex_thread::CodexThreadTurnContextOverrides;
|
||||
pub use codex_thread::ThreadConfigSnapshot;
|
||||
|
||||
@@ -3,6 +3,7 @@ use codex_config::config_toml::ConfigToml;
|
||||
use codex_protocol::config_types::Personality;
|
||||
use codex_thread_store::ListThreadsParams;
|
||||
use codex_thread_store::LocalThreadStore;
|
||||
use codex_thread_store::LocalThreadStoreConfig;
|
||||
use codex_thread_store::ThreadSortKey;
|
||||
use codex_thread_store::ThreadStore;
|
||||
use std::io;
|
||||
@@ -60,12 +61,10 @@ pub async fn maybe_migrate_personality(
|
||||
}
|
||||
|
||||
async fn has_recorded_sessions(codex_home: &Path, default_provider: &str) -> io::Result<bool> {
|
||||
let store = LocalThreadStore::new(codex_rollout::RolloutConfig {
|
||||
let store = LocalThreadStore::new(LocalThreadStoreConfig {
|
||||
codex_home: codex_home.to_path_buf(),
|
||||
sqlite_home: codex_home.to_path_buf(),
|
||||
cwd: codex_home.to_path_buf(),
|
||||
model_provider_id: default_provider.to_string(),
|
||||
generate_memories: false,
|
||||
default_model_provider_id: default_provider.to_string(),
|
||||
});
|
||||
if has_threads(&store, /*archived*/ false).await? {
|
||||
return Ok(true);
|
||||
|
||||
@@ -41,9 +41,9 @@ pub async fn build_prompt_input(
|
||||
SessionSource::Exec,
|
||||
Arc::new(EnvironmentManager::new(EnvironmentManagerArgs::new(local_runtime_paths)).await),
|
||||
/*analytics_events_client*/ None,
|
||||
thread_store_from_config(&config),
|
||||
);
|
||||
let thread_store = thread_store_from_config(&config);
|
||||
let thread = thread_manager.start_thread(config, thread_store).await?;
|
||||
let thread = thread_manager.start_thread(config).await?;
|
||||
|
||||
let output = build_prompt_input_from_session(thread.thread.codex.session.as_ref(), input).await;
|
||||
let shutdown = thread.thread.shutdown_and_wait().await;
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
use anyhow::Context;
|
||||
use codex_config::config_toml::ConfigLockfileToml;
|
||||
use codex_config::config_toml::ConfigToml;
|
||||
use codex_config::types::MemoriesToml;
|
||||
use codex_features::AppsMcpPathOverrideConfigToml;
|
||||
use codex_features::Feature;
|
||||
use codex_features::FeatureToml;
|
||||
use codex_features::FeaturesToml;
|
||||
use codex_features::MultiAgentV2ConfigToml;
|
||||
use codex_protocol::ThreadId;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::config_lock::ConfigLockReplayOptions;
|
||||
use crate::config_lock::clear_config_lock_debug_controls;
|
||||
use crate::config_lock::config_lockfile;
|
||||
use crate::config_lock::toml_round_trip;
|
||||
use crate::config_lock::validate_config_lock_replay;
|
||||
|
||||
use super::SessionConfiguration;
|
||||
|
||||
pub(crate) async fn validate_config_lock_if_configured(
|
||||
session_configuration: &SessionConfiguration,
|
||||
) -> anyhow::Result<()> {
|
||||
if session_configuration.session_source.is_non_root_agent() {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(expected) = session_configuration
|
||||
.original_config_do_not_use
|
||||
.config_lock_toml
|
||||
.as_ref()
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let actual = session_configuration.to_config_lockfile_toml()?;
|
||||
let config = session_configuration.original_config_do_not_use.as_ref();
|
||||
let options = ConfigLockReplayOptions {
|
||||
allow_codex_version_mismatch: config.config_lock_allow_codex_version_mismatch,
|
||||
};
|
||||
validate_config_lock_replay(expected, &actual, options)
|
||||
.context("config lock replay validation failed")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn export_config_lock_if_configured(
|
||||
session_configuration: &SessionConfiguration,
|
||||
conversation_id: ThreadId,
|
||||
) -> anyhow::Result<()> {
|
||||
let config = session_configuration.original_config_do_not_use.as_ref();
|
||||
let Some(export_dir) = config.config_lock_export_dir.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let lock = session_configuration.to_config_lockfile_toml()?;
|
||||
let lock = toml::to_string_pretty(&lock).context("failed to serialize config lock")?;
|
||||
let path = export_dir.join(format!("{conversation_id}.config.lock.toml"));
|
||||
|
||||
tokio::fs::create_dir_all(export_dir)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to create config lock export directory {}",
|
||||
export_dir.display()
|
||||
)
|
||||
})?;
|
||||
tokio::fs::write(&path, lock)
|
||||
.await
|
||||
.with_context(|| format!("failed to write config lock to {}", path.display()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl SessionConfiguration {
|
||||
pub(crate) fn to_config_lockfile_toml(&self) -> anyhow::Result<ConfigLockfileToml> {
|
||||
Ok(config_lockfile(session_configuration_to_lock_config_toml(
|
||||
self,
|
||||
)?))
|
||||
}
|
||||
}
|
||||
|
||||
fn session_configuration_to_lock_config_toml(
|
||||
sc: &SessionConfiguration,
|
||||
) -> anyhow::Result<ConfigToml> {
|
||||
let config = sc.original_config_do_not_use.as_ref();
|
||||
// Start from the resolved layer stack, then patch in values that are only
|
||||
// known after session setup. Export and replay validation both use this
|
||||
// path, so every field here is part of the lockfile contract.
|
||||
let mut lock_config: ConfigToml = config
|
||||
.config_layer_stack
|
||||
.effective_config()
|
||||
.try_into()
|
||||
.context("failed to deserialize effective config for config lock")?;
|
||||
|
||||
if config.config_lock_save_fields_resolved_from_model_catalog {
|
||||
save_session_resolved_fields(sc, &mut lock_config);
|
||||
}
|
||||
|
||||
save_config_resolved_fields(config, &mut lock_config)?;
|
||||
drop_lockfile_inputs(&mut lock_config);
|
||||
|
||||
Ok(lock_config)
|
||||
}
|
||||
|
||||
/// Saves values chosen during session construction from the model catalog,
|
||||
/// collaboration mode, and resolved prompt setup.
|
||||
///
|
||||
/// These values are not always present in the raw layer stack, so copy them
|
||||
/// from the live session when the lockfile should be fully self-contained.
|
||||
fn save_session_resolved_fields(sc: &SessionConfiguration, lock_config: &mut ConfigToml) {
|
||||
lock_config.model = Some(sc.collaboration_mode.model().to_string());
|
||||
lock_config.model_reasoning_effort = sc.collaboration_mode.reasoning_effort();
|
||||
lock_config.model_reasoning_summary = sc.model_reasoning_summary;
|
||||
lock_config.service_tier = sc.service_tier;
|
||||
lock_config.instructions = Some(sc.base_instructions.clone());
|
||||
lock_config.developer_instructions = sc.developer_instructions.clone();
|
||||
lock_config.compact_prompt = sc.compact_prompt.clone();
|
||||
lock_config.personality = sc.personality;
|
||||
lock_config.approval_policy = Some(sc.approval_policy.value());
|
||||
lock_config.approvals_reviewer = Some(sc.approvals_reviewer);
|
||||
}
|
||||
|
||||
/// Saves values stored on `Config` after higher-level resolution,
|
||||
/// normalization, defaulting, or feature materialization.
|
||||
///
|
||||
/// Persist the resolved representation so replay compares against the behavior
|
||||
/// Codex actually ran with, not only the user-authored TOML inputs.
|
||||
fn save_config_resolved_fields(
|
||||
config: &Config,
|
||||
lock_config: &mut ConfigToml,
|
||||
) -> anyhow::Result<()> {
|
||||
lock_config.web_search = Some(config.web_search_mode.value());
|
||||
lock_config.model_provider = Some(config.model_provider_id.clone());
|
||||
lock_config.plan_mode_reasoning_effort = config.plan_mode_reasoning_effort;
|
||||
lock_config.model_verbosity = config.model_verbosity;
|
||||
lock_config.include_permissions_instructions = Some(config.include_permissions_instructions);
|
||||
lock_config.include_apps_instructions = Some(config.include_apps_instructions);
|
||||
lock_config.include_environment_context = Some(config.include_environment_context);
|
||||
lock_config.background_terminal_max_timeout = Some(config.background_terminal_max_timeout);
|
||||
|
||||
// Feature aliases and feature configs need to be written in their resolved
|
||||
// form; otherwise replay can drift when a legacy key maps to the same
|
||||
// runtime feature.
|
||||
let features = lock_config
|
||||
.features
|
||||
.get_or_insert_with(FeaturesToml::default);
|
||||
features.materialize_resolved_enabled(config.features.get());
|
||||
let mut multi_agent_v2: MultiAgentV2ConfigToml =
|
||||
resolved_config_to_toml(&config.multi_agent_v2, "features.multi_agent_v2")?;
|
||||
multi_agent_v2.enabled = Some(config.features.enabled(Feature::MultiAgentV2));
|
||||
features.multi_agent_v2 = Some(FeatureToml::Config(multi_agent_v2));
|
||||
features.apps_mcp_path_override = Some(FeatureToml::Config(AppsMcpPathOverrideConfigToml {
|
||||
enabled: Some(config.features.enabled(Feature::AppsMcpPathOverride)),
|
||||
path: config.apps_mcp_path_override.clone(),
|
||||
}));
|
||||
lock_config.memories = Some(resolved_config_to_toml::<MemoriesToml>(
|
||||
&config.memories,
|
||||
"memories",
|
||||
)?);
|
||||
|
||||
let agents = lock_config.agents.get_or_insert_with(Default::default);
|
||||
// Multi-agent v2 owns thread fanout through its feature config. Preserve
|
||||
// the legacy agents.max_threads setting only when v2 is disabled.
|
||||
agents.max_threads = if config.features.enabled(Feature::MultiAgentV2) {
|
||||
None
|
||||
} else {
|
||||
config.agent_max_threads
|
||||
};
|
||||
agents.max_depth = Some(config.agent_max_depth);
|
||||
agents.job_max_runtime_seconds = config.agent_job_max_runtime_seconds;
|
||||
agents.interrupt_message = Some(config.agent_interrupt_message_enabled);
|
||||
|
||||
lock_config
|
||||
.skills
|
||||
.get_or_insert_with(Default::default)
|
||||
.include_instructions = Some(config.include_skill_instructions);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn drop_lockfile_inputs(lock_config: &mut ConfigToml) {
|
||||
// The lockfile should contain replayable values, not the profile,
|
||||
// debug-control, file-include, and environment-specific inputs that
|
||||
// produced those values in the original session.
|
||||
lock_config.profile = None;
|
||||
lock_config.profiles.clear();
|
||||
clear_config_lock_debug_controls(lock_config);
|
||||
lock_config.model_instructions_file = None;
|
||||
lock_config.experimental_instructions_file = None;
|
||||
lock_config.experimental_compact_prompt_file = None;
|
||||
lock_config.model_catalog_json = None;
|
||||
lock_config.sandbox_mode = None;
|
||||
lock_config.sandbox_workspace_write = None;
|
||||
lock_config.default_permissions = None;
|
||||
lock_config.permissions = None;
|
||||
lock_config.experimental_use_unified_exec_tool = None;
|
||||
lock_config.experimental_use_freeform_apply_patch = None;
|
||||
}
|
||||
|
||||
fn resolved_config_to_toml<Toml>(
|
||||
value: &impl serde::Serialize,
|
||||
label: &'static str,
|
||||
) -> anyhow::Result<Toml>
|
||||
where
|
||||
Toml: serde::de::DeserializeOwned + serde::Serialize,
|
||||
{
|
||||
toml_round_trip(value, label).map_err(anyhow::Error::from)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::test]
|
||||
async fn lock_contains_prompts_and_materializes_features() {
|
||||
let mut sc = crate::session::tests::make_session_configuration_for_tests().await;
|
||||
sc.base_instructions = "resolved instructions".to_string();
|
||||
sc.developer_instructions = Some("resolved developer instructions".to_string());
|
||||
sc.compact_prompt = Some("resolved compact prompt".to_string());
|
||||
|
||||
let lockfile = sc.to_config_lockfile_toml().expect("lock should serialize");
|
||||
let lock = &lockfile.config;
|
||||
|
||||
assert_eq!(lock.instructions, Some(sc.base_instructions.clone()));
|
||||
assert_eq!(lock.developer_instructions, sc.developer_instructions);
|
||||
assert_eq!(lock.compact_prompt, sc.compact_prompt);
|
||||
assert_eq!(lock.model, Some(sc.collaboration_mode.model().to_string()));
|
||||
assert_eq!(
|
||||
lock.model_reasoning_effort,
|
||||
sc.collaboration_mode.reasoning_effort()
|
||||
);
|
||||
assert_eq!(lock.profile, None);
|
||||
assert!(lock.profiles.is_empty());
|
||||
assert!(
|
||||
lock.debug
|
||||
.as_ref()
|
||||
.is_none_or(|debug| debug.config_lockfile.is_none())
|
||||
);
|
||||
assert!(lock.memories.is_some());
|
||||
|
||||
let features = lock
|
||||
.features
|
||||
.as_ref()
|
||||
.expect("lock should materialize feature states");
|
||||
let feature_entries = features.entries();
|
||||
for spec in codex_features::FEATURES {
|
||||
assert_eq!(
|
||||
feature_entries.get(spec.key),
|
||||
Some(&sc.original_config_do_not_use.features.enabled(spec.id)),
|
||||
"{}",
|
||||
spec.key
|
||||
);
|
||||
}
|
||||
|
||||
let multi_agent_v2 = features
|
||||
.multi_agent_v2
|
||||
.as_ref()
|
||||
.expect("multi_agent_v2 config should be materialized");
|
||||
assert!(matches!(
|
||||
multi_agent_v2,
|
||||
FeatureToml::Config(MultiAgentV2ConfigToml {
|
||||
enabled: Some(false),
|
||||
max_concurrent_threads_per_session: Some(_),
|
||||
min_wait_timeout_ms: Some(_),
|
||||
usage_hint_enabled: Some(_),
|
||||
hide_spawn_agent_metadata: Some(_),
|
||||
..
|
||||
})
|
||||
));
|
||||
|
||||
assert_eq!(lockfile.version, crate::config_lock::CONFIG_LOCK_VERSION);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lock_skips_session_values_when_model_catalog_fields_are_not_saved() {
|
||||
let mut sc = crate::session::tests::make_session_configuration_for_tests().await;
|
||||
let mut config = (*sc.original_config_do_not_use).clone();
|
||||
config.config_lock_save_fields_resolved_from_model_catalog = false;
|
||||
sc.original_config_do_not_use = Arc::new(config);
|
||||
sc.base_instructions = "catalog instructions".to_string();
|
||||
sc.developer_instructions = Some("catalog developer instructions".to_string());
|
||||
sc.compact_prompt = Some("catalog compact prompt".to_string());
|
||||
sc.service_tier = Some(codex_protocol::config_types::ServiceTier::Flex);
|
||||
|
||||
let lockfile = sc.to_config_lockfile_toml().expect("lock should serialize");
|
||||
let lock = &lockfile.config;
|
||||
|
||||
assert_eq!(lock.model, None);
|
||||
assert_eq!(lock.model_reasoning_effort, None);
|
||||
assert_eq!(lock.model_reasoning_summary, None);
|
||||
assert_eq!(lock.service_tier, None);
|
||||
assert_eq!(lock.instructions, None);
|
||||
assert_eq!(lock.developer_instructions, None);
|
||||
assert_eq!(lock.compact_prompt, None);
|
||||
assert_eq!(lock.personality, None);
|
||||
assert_eq!(lock.approval_policy, None);
|
||||
assert_eq!(lock.approvals_reviewer, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lock_validation_reports_config_diff() {
|
||||
let sc = crate::session::tests::make_session_configuration_for_tests().await;
|
||||
let expected = sc.to_config_lockfile_toml().expect("lock should serialize");
|
||||
let mut actual = expected.clone();
|
||||
actual.config.model = Some("different-model".to_string());
|
||||
|
||||
let error =
|
||||
validate_config_lock_replay(&expected, &actual, ConfigLockReplayOptions::default())
|
||||
.expect_err("config drift should fail");
|
||||
let message = error.to_string();
|
||||
assert!(
|
||||
message.contains("replayed effective config does not match config lock"),
|
||||
"{message}"
|
||||
);
|
||||
assert!(message.contains("model = "), "{message}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lock_validation_rejects_codex_version_mismatch_by_default() {
|
||||
let sc = crate::session::tests::make_session_configuration_for_tests().await;
|
||||
let mut expected = sc.to_config_lockfile_toml().expect("lock should serialize");
|
||||
expected.codex_version = "older-version".to_string();
|
||||
let actual = sc.to_config_lockfile_toml().expect("lock should serialize");
|
||||
|
||||
let error =
|
||||
validate_config_lock_replay(&expected, &actual, ConfigLockReplayOptions::default())
|
||||
.expect_err("version drift should fail");
|
||||
let message = error.to_string();
|
||||
assert!(
|
||||
message.contains("config lock Codex version mismatch"),
|
||||
"{message}"
|
||||
);
|
||||
assert!(
|
||||
message.contains("debug.config_lockfile.allow_codex_version_mismatch=true"),
|
||||
"{message}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lock_validation_can_ignore_codex_version_mismatch() {
|
||||
let sc = crate::session::tests::make_session_configuration_for_tests().await;
|
||||
let mut expected = sc.to_config_lockfile_toml().expect("lock should serialize");
|
||||
expected.codex_version = "older-version".to_string();
|
||||
let actual = sc.to_config_lockfile_toml().expect("lock should serialize");
|
||||
|
||||
validate_config_lock_replay(
|
||||
&expected,
|
||||
&actual,
|
||||
ConfigLockReplayOptions {
|
||||
allow_codex_version_mismatch: true,
|
||||
},
|
||||
)
|
||||
.expect("version drift should be ignored");
|
||||
}
|
||||
}
|
||||
@@ -136,6 +136,7 @@ use codex_thread_store::LiveThreadInitGuard;
|
||||
use codex_thread_store::LocalThreadStore;
|
||||
use codex_thread_store::ResumeThreadParams;
|
||||
use codex_thread_store::ThreadEventPersistenceMode;
|
||||
use codex_thread_store::ThreadPersistenceMetadata;
|
||||
use codex_thread_store::ThreadStore;
|
||||
use codex_utils_output_truncation::TruncationPolicy;
|
||||
use futures::future::BoxFuture;
|
||||
@@ -184,6 +185,7 @@ use codex_protocol::error::Result as CodexResult;
|
||||
#[cfg(test)]
|
||||
use codex_protocol::exec_output::StreamOutput;
|
||||
|
||||
mod config_lock;
|
||||
mod handlers;
|
||||
mod mcp;
|
||||
mod multi_agents;
|
||||
@@ -193,6 +195,8 @@ mod rollout_reconstruction;
|
||||
pub(crate) mod session;
|
||||
pub(crate) mod turn;
|
||||
pub(crate) mod turn_context;
|
||||
use self::config_lock::export_config_lock_if_configured;
|
||||
use self::config_lock::validate_config_lock_if_configured;
|
||||
#[cfg(test)]
|
||||
use self::handlers::submission_dispatch_span;
|
||||
use self::handlers::submission_loop;
|
||||
@@ -347,6 +351,7 @@ use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata;
|
||||
use codex_protocol::protocol::SkillToolDependency as ProtocolSkillToolDependency;
|
||||
use codex_protocol::protocol::StreamErrorEvent;
|
||||
use codex_protocol::protocol::Submission;
|
||||
use codex_protocol::protocol::ThreadMemoryMode;
|
||||
use codex_protocol::protocol::TokenCountEvent;
|
||||
use codex_protocol::protocol::TokenUsage;
|
||||
use codex_protocol::protocol::TokenUsageInfo;
|
||||
|
||||
@@ -400,6 +400,15 @@ impl Session {
|
||||
text: session_configuration.base_instructions.clone(),
|
||||
},
|
||||
dynamic_tools: session_configuration.dynamic_tools.clone(),
|
||||
metadata: ThreadPersistenceMetadata {
|
||||
cwd: Some(config.cwd.to_path_buf()),
|
||||
model_provider: config.model_provider_id.clone(),
|
||||
memory_mode: if config.memories.generate_memories {
|
||||
ThreadMemoryMode::Enabled
|
||||
} else {
|
||||
ThreadMemoryMode::Disabled
|
||||
},
|
||||
},
|
||||
event_persistence_mode,
|
||||
},
|
||||
)
|
||||
@@ -413,6 +422,15 @@ impl Session {
|
||||
rollout_path: resumed_history.rollout_path.clone(),
|
||||
history: Some(resumed_history.history.clone()),
|
||||
include_archived: true,
|
||||
metadata: ThreadPersistenceMetadata {
|
||||
cwd: Some(config.cwd.to_path_buf()),
|
||||
model_provider: config.model_provider_id.clone(),
|
||||
memory_mode: if config.memories.generate_memories {
|
||||
ThreadMemoryMode::Enabled
|
||||
} else {
|
||||
ThreadMemoryMode::Disabled
|
||||
},
|
||||
},
|
||||
event_persistence_mode,
|
||||
},
|
||||
)
|
||||
@@ -705,6 +723,8 @@ impl Session {
|
||||
))
|
||||
.await;
|
||||
session_configuration.thread_name = thread_name.clone();
|
||||
validate_config_lock_if_configured(&session_configuration).await?;
|
||||
export_config_lock_if_configured(&session_configuration, conversation_id).await?;
|
||||
let state = SessionState::new(session_configuration.clone());
|
||||
let managed_network_requirements_configured = config
|
||||
.config_layer_stack
|
||||
|
||||
@@ -118,6 +118,8 @@ use codex_protocol::protocol::TurnCompleteEvent;
|
||||
use codex_protocol::protocol::TurnStartedEvent;
|
||||
use codex_protocol::protocol::UserMessageEvent;
|
||||
use codex_protocol::protocol::W3cTraceContext;
|
||||
use codex_protocol::request_user_input::RequestUserInputAnswer;
|
||||
use codex_protocol::request_user_input::RequestUserInputResponse;
|
||||
use core_test_support::PathBufExt;
|
||||
use core_test_support::PathExt;
|
||||
use core_test_support::context_snapshot;
|
||||
@@ -136,6 +138,7 @@ use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::test_path_buf;
|
||||
use core_test_support::tracing::install_test_tracing;
|
||||
use core_test_support::wait_for_event;
|
||||
use core_test_support::wait_for_event_match;
|
||||
use opentelemetry::trace::TraceContextExt;
|
||||
use opentelemetry::trace::TraceId;
|
||||
use opentelemetry_sdk::metrics::InMemoryMetricExporter;
|
||||
@@ -1679,9 +1682,6 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result<
|
||||
.fork_thread(
|
||||
usize::MAX,
|
||||
fork_config.clone(),
|
||||
std::sync::Arc::new(codex_thread_store::LocalThreadStore::new(
|
||||
codex_rollout::RolloutConfig::from_view(&fork_config),
|
||||
)),
|
||||
rollout_path,
|
||||
/*persist_extended_history*/ false,
|
||||
/*parent_trace*/ None,
|
||||
@@ -2720,6 +2720,7 @@ async fn wait_for_thread_rollback_failed(rx: &async_channel::Receiver<Event>) ->
|
||||
}
|
||||
|
||||
async fn attach_thread_persistence(session: &mut Session) -> PathBuf {
|
||||
let config = session.get_config().await;
|
||||
let live_thread = LiveThread::create(
|
||||
Arc::clone(&session.services.thread_store),
|
||||
CreateThreadParams {
|
||||
@@ -2728,6 +2729,15 @@ async fn attach_thread_persistence(session: &mut Session) -> PathBuf {
|
||||
source: SessionSource::Exec,
|
||||
base_instructions: BaseInstructions::default(),
|
||||
dynamic_tools: Vec::new(),
|
||||
metadata: ThreadPersistenceMetadata {
|
||||
cwd: Some(config.cwd.to_path_buf()),
|
||||
model_provider: config.model_provider_id.clone(),
|
||||
memory_mode: if config.memories.generate_memories {
|
||||
ThreadMemoryMode::Enabled
|
||||
} else {
|
||||
ThreadMemoryMode::Disabled
|
||||
},
|
||||
},
|
||||
event_persistence_mode: ThreadEventPersistenceMode::Limited,
|
||||
},
|
||||
)
|
||||
@@ -3392,7 +3402,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() {
|
||||
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
||||
/*analytics_events_client*/ None,
|
||||
Arc::new(codex_thread_store::LocalThreadStore::new(
|
||||
codex_rollout::RolloutConfig::from_view(config.as_ref()),
|
||||
codex_thread_store::LocalThreadStoreConfig::from_config(config.as_ref()),
|
||||
)),
|
||||
codex_rollout_trace::ThreadTraceContext::disabled(),
|
||||
)
|
||||
@@ -3539,7 +3549,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
state_db: None,
|
||||
live_thread: None,
|
||||
thread_store: Arc::new(codex_thread_store::LocalThreadStore::new(
|
||||
codex_rollout::RolloutConfig::from_view(config.as_ref()),
|
||||
codex_thread_store::LocalThreadStoreConfig::from_config(config.as_ref()),
|
||||
)),
|
||||
model_client: ModelClient::new(
|
||||
Some(auth_manager.clone()),
|
||||
@@ -3711,7 +3721,7 @@ async fn make_session_with_config_and_rx(
|
||||
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
||||
/*analytics_events_client*/ None,
|
||||
Arc::new(codex_thread_store::LocalThreadStore::new(
|
||||
codex_rollout::RolloutConfig::from_view(config.as_ref()),
|
||||
codex_thread_store::LocalThreadStoreConfig::from_config(config.as_ref()),
|
||||
)),
|
||||
codex_rollout_trace::ThreadTraceContext::disabled(),
|
||||
)
|
||||
@@ -4574,6 +4584,7 @@ async fn shutdown_complete_does_not_append_to_thread_store_after_shutdown() {
|
||||
let (mut session, _turn_context) = make_session_and_context().await;
|
||||
let store = Arc::new(codex_thread_store::InMemoryThreadStore::default());
|
||||
let thread_store: Arc<dyn codex_thread_store::ThreadStore> = store.clone();
|
||||
let config = session.get_config().await;
|
||||
let live_thread = LiveThread::create(
|
||||
Arc::clone(&thread_store),
|
||||
CreateThreadParams {
|
||||
@@ -4582,6 +4593,15 @@ async fn shutdown_complete_does_not_append_to_thread_store_after_shutdown() {
|
||||
source: SessionSource::Exec,
|
||||
base_instructions: BaseInstructions::default(),
|
||||
dynamic_tools: Vec::new(),
|
||||
metadata: ThreadPersistenceMetadata {
|
||||
cwd: Some(config.cwd.to_path_buf()),
|
||||
model_provider: config.model_provider_id.clone(),
|
||||
memory_mode: if config.memories.generate_memories {
|
||||
ThreadMemoryMode::Enabled
|
||||
} else {
|
||||
ThreadMemoryMode::Disabled
|
||||
},
|
||||
},
|
||||
event_persistence_mode: ThreadEventPersistenceMode::Limited,
|
||||
},
|
||||
)
|
||||
@@ -4968,7 +4988,7 @@ where
|
||||
state_db: None,
|
||||
live_thread: None,
|
||||
thread_store: Arc::new(codex_thread_store::LocalThreadStore::new(
|
||||
codex_rollout::RolloutConfig::from_view(config.as_ref()),
|
||||
codex_thread_store::LocalThreadStoreConfig::from_config(config.as_ref()),
|
||||
)),
|
||||
model_client: ModelClient::new(
|
||||
Some(Arc::clone(&auth_manager)),
|
||||
@@ -6941,7 +6961,7 @@ async fn interrupt_accounts_active_goal_before_pausing() -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn active_goal_continuation_runs_to_completion_after_turn() -> anyhow::Result<()> {
|
||||
async fn active_goal_continuation_runs_again_after_no_tool_turn() -> anyhow::Result<()> {
|
||||
let server = start_mock_server().await;
|
||||
let mut builder = test_codex().with_config(|config| {
|
||||
config
|
||||
@@ -6967,17 +6987,21 @@ async fn active_goal_continuation_runs_to_completion_after_turn() -> anyhow::Res
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-3"),
|
||||
ev_assistant_message("msg-2", "I am still working on the benchmark note."),
|
||||
ev_completed("resp-3"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-4"),
|
||||
ev_function_call(
|
||||
"call-complete-goal",
|
||||
"update_goal",
|
||||
r#"{"status":"complete"}"#,
|
||||
),
|
||||
ev_completed("resp-3"),
|
||||
ev_completed("resp-4"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-2", "Goal complete."),
|
||||
ev_completed("resp-4"),
|
||||
ev_assistant_message("msg-3", "Goal complete."),
|
||||
ev_completed("resp-5"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
@@ -7001,7 +7025,7 @@ async fn active_goal_continuation_runs_to_completion_after_turn() -> anyhow::Res
|
||||
let event = test.codex.next_event().await?;
|
||||
if matches!(event.msg, EventMsg::TurnComplete(_)) {
|
||||
completed_turns += 1;
|
||||
if completed_turns == 2 {
|
||||
if completed_turns == 3 {
|
||||
return anyhow::Ok(());
|
||||
}
|
||||
}
|
||||
@@ -7012,6 +7036,125 @@ async fn active_goal_continuation_runs_to_completion_after_turn() -> anyhow::Res
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn pending_request_user_input_does_not_spawn_extra_goal_continuation() -> anyhow::Result<()> {
|
||||
let server = start_mock_server().await;
|
||||
let mut builder = test_codex().with_config(|config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::Goals)
|
||||
.expect("goal mode should be enableable in tests");
|
||||
config
|
||||
.features
|
||||
.enable(Feature::DefaultModeRequestUserInput)
|
||||
.expect("default-mode request_user_input should be enableable in tests");
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
let responses = mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_function_call(
|
||||
"call-create-goal",
|
||||
"create_goal",
|
||||
r#"{"objective":"write a benchmark note"}"#,
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "Draft ready."),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-3"),
|
||||
ev_function_call(
|
||||
"call-ask-user",
|
||||
"request_user_input",
|
||||
r#"{"questions":[{"header":"Choice","id":"next_step","question":"Pick one","options":[{"label":"Outline","description":"Start with an outline."},{"label":"Draft","description":"Write a full draft."}]}]}"#,
|
||||
),
|
||||
ev_completed("resp-3"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-4"),
|
||||
ev_function_call(
|
||||
"call-complete-goal",
|
||||
"update_goal",
|
||||
r#"{"status":"complete"}"#,
|
||||
),
|
||||
ev_completed("resp-4"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-2", "Goal complete."),
|
||||
ev_completed("resp-5"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
test.codex
|
||||
.submit(Op::UserInput {
|
||||
environments: None,
|
||||
items: vec![UserInput::Text {
|
||||
text: "write a benchmark note".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
responsesapi_client_metadata: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let request_user_input_event = wait_for_event_match(&test.codex, |event| match event {
|
||||
EventMsg::RequestUserInput(event) => Some(event.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
assert_eq!(3, responses.requests().len());
|
||||
assert!(
|
||||
timeout(Duration::from_millis(200), test.codex.next_event())
|
||||
.await
|
||||
.is_err(),
|
||||
"waiting for request_user_input should keep the turn open without emitting more events"
|
||||
);
|
||||
assert_eq!(
|
||||
3,
|
||||
responses.requests().len(),
|
||||
"waiting for request_user_input should not start another continuation request"
|
||||
);
|
||||
|
||||
test.codex
|
||||
.submit(Op::UserInputAnswer {
|
||||
id: request_user_input_event.turn_id,
|
||||
response: RequestUserInputResponse {
|
||||
answers: std::collections::HashMap::from([(
|
||||
"next_step".to_string(),
|
||||
RequestUserInputAnswer {
|
||||
answers: vec!["Outline".to_string()],
|
||||
},
|
||||
)]),
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
|
||||
let mut completed_turns = 0;
|
||||
timeout(Duration::from_secs(8), async {
|
||||
loop {
|
||||
let event = test.codex.next_event().await?;
|
||||
if matches!(event.msg, EventMsg::TurnComplete(_)) {
|
||||
completed_turns += 1;
|
||||
if completed_turns == 1 {
|
||||
return anyhow::Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await??;
|
||||
|
||||
assert_eq!(5, responses.requests().len());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_total_token_usage(sess: &Session, total_token_usage: TokenUsage) {
|
||||
let mut state = sess.state.lock().await;
|
||||
state.set_token_info(Some(TokenUsageInfo {
|
||||
|
||||
@@ -729,7 +729,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() {
|
||||
let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager)));
|
||||
let skills_watcher = Arc::new(SkillsWatcher::noop());
|
||||
let thread_store = Arc::new(codex_thread_store::LocalThreadStore::new(
|
||||
codex_rollout::RolloutConfig::from_view(&config),
|
||||
codex_thread_store::LocalThreadStoreConfig::from_config(&config),
|
||||
));
|
||||
|
||||
let CodexSpawnOk { codex, .. } = Codex::spawn(CodexSpawnArgs {
|
||||
|
||||
@@ -255,14 +255,14 @@ pub(crate) async fn handle_output_item_done(
|
||||
}
|
||||
// No tool call: convert messages/reasoning into turn items and mark them as complete.
|
||||
Ok(None) => {
|
||||
if let Some(turn_item) = handle_non_tool_response_item(
|
||||
let turn_item = handle_non_tool_response_item(
|
||||
ctx.sess.as_ref(),
|
||||
ctx.turn_context.as_ref(),
|
||||
&item,
|
||||
plan_mode,
|
||||
)
|
||||
.await
|
||||
{
|
||||
.await;
|
||||
if let Some(turn_item) = turn_item {
|
||||
if previously_active_item.is_none() {
|
||||
let mut started_item = turn_item.clone();
|
||||
if let TurnItem::ImageGeneration(item) = &mut started_item {
|
||||
|
||||
@@ -735,7 +735,6 @@ impl Session {
|
||||
.goal_runtime_apply(GoalRuntimeEvent::TurnFinished {
|
||||
turn_context: turn_context.as_ref(),
|
||||
turn_completed: should_clear_active_turn,
|
||||
tool_calls: turn_tool_calls,
|
||||
})
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -20,7 +20,6 @@ use codex_models_manager::test_support::get_model_offline_for_tests;
|
||||
use codex_protocol::config_types::CollaborationModeMask;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::openai_models::ModelPreset;
|
||||
use codex_thread_store::ThreadStore;
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
use crate::ThreadManager;
|
||||
@@ -77,18 +76,16 @@ pub fn thread_manager_with_models_provider_and_home(
|
||||
pub async fn start_thread_with_user_shell_override(
|
||||
thread_manager: &ThreadManager,
|
||||
config: Config,
|
||||
thread_store: Arc<dyn ThreadStore>,
|
||||
user_shell_override: crate::shell::Shell,
|
||||
) -> codex_protocol::error::Result<crate::NewThread> {
|
||||
thread_manager
|
||||
.start_thread_with_user_shell_override_for_tests(config, thread_store, user_shell_override)
|
||||
.start_thread_with_user_shell_override_for_tests(config, user_shell_override)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn resume_thread_from_rollout_with_user_shell_override(
|
||||
thread_manager: &ThreadManager,
|
||||
config: Config,
|
||||
thread_store: Arc<dyn ThreadStore>,
|
||||
rollout_path: PathBuf,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
user_shell_override: crate::shell::Shell,
|
||||
@@ -96,7 +93,6 @@ pub async fn resume_thread_from_rollout_with_user_shell_override(
|
||||
thread_manager
|
||||
.resume_thread_from_rollout_with_user_shell_override_for_tests(
|
||||
config,
|
||||
thread_store,
|
||||
rollout_path,
|
||||
auth_manager,
|
||||
user_shell_override,
|
||||
|
||||
@@ -28,6 +28,7 @@ use codex_login::AuthManager;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_model_provider::create_model_provider;
|
||||
use codex_model_provider_info::ModelProviderInfo;
|
||||
use codex_model_provider_info::OPENAI_PROVIDER_ID;
|
||||
use codex_models_manager::manager::RefreshStrategy;
|
||||
use codex_models_manager::manager::SharedModelsManager;
|
||||
use codex_protocol::ThreadId;
|
||||
@@ -50,12 +51,15 @@ use codex_protocol::protocol::TurnAbortReason;
|
||||
use codex_protocol::protocol::TurnAbortedEvent;
|
||||
use codex_protocol::protocol::TurnEnvironmentSelection;
|
||||
use codex_protocol::protocol::W3cTraceContext;
|
||||
use codex_rollout::RolloutConfig;
|
||||
use codex_state::DirectionalThreadSpawnEdgeStatus;
|
||||
use codex_thread_store::InMemoryThreadStore;
|
||||
use codex_thread_store::LocalThreadStore;
|
||||
use codex_thread_store::LocalThreadStoreConfig;
|
||||
use codex_thread_store::ReadThreadParams;
|
||||
use codex_thread_store::RemoteThreadStore;
|
||||
use codex_thread_store::StoredThread;
|
||||
use codex_thread_store::ThreadStore;
|
||||
use codex_thread_store::ThreadStoreError;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use futures::StreamExt;
|
||||
use futures::stream::FuturesUnordered;
|
||||
@@ -211,7 +215,6 @@ pub struct ThreadManager {
|
||||
|
||||
pub struct StartThreadOptions {
|
||||
pub config: Config,
|
||||
pub thread_store: Arc<dyn ThreadStore>,
|
||||
pub initial_history: InitialHistory,
|
||||
pub session_source: Option<SessionSource>,
|
||||
pub dynamic_tools: Vec<codex_protocol::dynamic_tools::DynamicToolSpec>,
|
||||
@@ -221,10 +224,9 @@ pub struct StartThreadOptions {
|
||||
pub environments: Vec<TurnEnvironmentSelection>,
|
||||
}
|
||||
|
||||
pub(crate) struct ResumeThreadFromRolloutOptions {
|
||||
pub(crate) struct ResumeThreadWithHistoryOptions {
|
||||
pub(crate) config: Config,
|
||||
pub(crate) thread_store: Arc<dyn ThreadStore>,
|
||||
pub(crate) rollout_path: PathBuf,
|
||||
pub(crate) initial_history: InitialHistory,
|
||||
pub(crate) agent_control: AgentControl,
|
||||
pub(crate) session_source: SessionSource,
|
||||
pub(crate) inherited_shell_snapshot: Option<Arc<ShellSnapshot>>,
|
||||
@@ -244,6 +246,7 @@ pub(crate) struct ThreadManagerState {
|
||||
plugins_manager: Arc<PluginsManager>,
|
||||
mcp_manager: Arc<McpManager>,
|
||||
skills_watcher: Arc<SkillsWatcher>,
|
||||
thread_store: Arc<dyn ThreadStore>,
|
||||
session_source: SessionSource,
|
||||
analytics_events_client: Option<AnalyticsEventsClient>,
|
||||
// Captures submitted ops for testing purpose when test mode is enabled.
|
||||
@@ -263,9 +266,9 @@ pub fn build_models_manager(
|
||||
|
||||
pub fn thread_store_from_config(config: &Config) -> Arc<dyn ThreadStore> {
|
||||
match &config.experimental_thread_store {
|
||||
ThreadStoreConfig::Local => {
|
||||
Arc::new(LocalThreadStore::new(RolloutConfig::from_view(config)))
|
||||
}
|
||||
ThreadStoreConfig::Local => Arc::new(LocalThreadStore::new(
|
||||
LocalThreadStoreConfig::from_config(config),
|
||||
)),
|
||||
ThreadStoreConfig::Remote { endpoint } => Arc::new(RemoteThreadStore::new(endpoint)),
|
||||
ThreadStoreConfig::InMemory { id } => InMemoryThreadStore::for_id(id),
|
||||
}
|
||||
@@ -278,6 +281,7 @@ impl ThreadManager {
|
||||
session_source: SessionSource,
|
||||
environment_manager: Arc<EnvironmentManager>,
|
||||
analytics_events_client: Option<AnalyticsEventsClient>,
|
||||
thread_store: Arc<dyn ThreadStore>,
|
||||
) -> Self {
|
||||
let codex_home = config.codex_home.clone();
|
||||
let restriction_product = session_source.restriction_product();
|
||||
@@ -303,6 +307,7 @@ impl ThreadManager {
|
||||
plugins_manager,
|
||||
mcp_manager,
|
||||
skills_watcher,
|
||||
thread_store,
|
||||
auth_manager,
|
||||
session_source,
|
||||
analytics_events_client,
|
||||
@@ -363,6 +368,14 @@ impl ThreadManager {
|
||||
restriction_product,
|
||||
));
|
||||
let skills_watcher = build_skills_watcher(Arc::clone(&skills_manager));
|
||||
// This test constructor has no Config input. Tests that need a non-local
|
||||
// process store should construct ThreadManager::new with an explicit store.
|
||||
let thread_store: Arc<dyn ThreadStore> =
|
||||
Arc::new(LocalThreadStore::new(LocalThreadStoreConfig {
|
||||
codex_home: codex_home.clone(),
|
||||
sqlite_home: codex_home.clone(),
|
||||
default_model_provider_id: OPENAI_PROVIDER_ID.to_string(),
|
||||
}));
|
||||
Self {
|
||||
state: Arc::new(ThreadManagerState {
|
||||
threads: Arc::new(RwLock::new(HashMap::new())),
|
||||
@@ -374,6 +387,7 @@ impl ThreadManager {
|
||||
plugins_manager,
|
||||
mcp_manager,
|
||||
skills_watcher,
|
||||
thread_store,
|
||||
auth_manager,
|
||||
session_source: SessionSource::Exec,
|
||||
analytics_events_client: None,
|
||||
@@ -517,16 +531,11 @@ impl ThreadManager {
|
||||
Ok(subtree_thread_ids)
|
||||
}
|
||||
|
||||
pub async fn start_thread(
|
||||
&self,
|
||||
config: Config,
|
||||
thread_store: Arc<dyn ThreadStore>,
|
||||
) -> CodexResult<NewThread> {
|
||||
pub async fn start_thread(&self, config: Config) -> CodexResult<NewThread> {
|
||||
// Box delegated thread-spawn futures so these convenience wrappers do
|
||||
// not inline the full spawn path into every caller's async state.
|
||||
Box::pin(self.start_thread_with_tools(
|
||||
config,
|
||||
thread_store,
|
||||
Vec::new(),
|
||||
/*persist_extended_history*/ false,
|
||||
))
|
||||
@@ -536,7 +545,6 @@ impl ThreadManager {
|
||||
pub async fn start_thread_with_tools(
|
||||
&self,
|
||||
config: Config,
|
||||
thread_store: Arc<dyn ThreadStore>,
|
||||
dynamic_tools: Vec<codex_protocol::dynamic_tools::DynamicToolSpec>,
|
||||
persist_extended_history: bool,
|
||||
) -> CodexResult<NewThread> {
|
||||
@@ -546,7 +554,6 @@ impl ThreadManager {
|
||||
);
|
||||
Box::pin(self.start_thread_with_options(StartThreadOptions {
|
||||
config,
|
||||
thread_store,
|
||||
initial_history: InitialHistory::New,
|
||||
session_source: None,
|
||||
dynamic_tools,
|
||||
@@ -567,7 +574,6 @@ impl ThreadManager {
|
||||
.unwrap_or_else(|| self.state.session_source.clone());
|
||||
Box::pin(self.state.spawn_thread_with_source(
|
||||
options.config,
|
||||
options.thread_store,
|
||||
options.initial_history,
|
||||
Arc::clone(&self.state.auth_manager),
|
||||
self.agent_control(),
|
||||
@@ -587,7 +593,6 @@ impl ThreadManager {
|
||||
pub async fn resume_thread_from_rollout(
|
||||
&self,
|
||||
config: Config,
|
||||
thread_store: Arc<dyn ThreadStore>,
|
||||
rollout_path: PathBuf,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
parent_trace: Option<W3cTraceContext>,
|
||||
@@ -595,7 +600,6 @@ impl ThreadManager {
|
||||
let initial_history = RolloutRecorder::get_rollout_history(&rollout_path).await?;
|
||||
Box::pin(self.resume_thread_with_history(
|
||||
config,
|
||||
thread_store,
|
||||
initial_history,
|
||||
auth_manager,
|
||||
/*persist_extended_history*/ false,
|
||||
@@ -607,7 +611,6 @@ impl ThreadManager {
|
||||
pub async fn resume_thread_with_history(
|
||||
&self,
|
||||
config: Config,
|
||||
thread_store: Arc<dyn ThreadStore>,
|
||||
initial_history: InitialHistory,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
persist_extended_history: bool,
|
||||
@@ -619,7 +622,6 @@ impl ThreadManager {
|
||||
);
|
||||
Box::pin(self.state.spawn_thread(
|
||||
config,
|
||||
thread_store,
|
||||
initial_history,
|
||||
auth_manager,
|
||||
self.agent_control(),
|
||||
@@ -636,7 +638,6 @@ impl ThreadManager {
|
||||
pub(crate) async fn start_thread_with_user_shell_override_for_tests(
|
||||
&self,
|
||||
config: Config,
|
||||
thread_store: Arc<dyn ThreadStore>,
|
||||
user_shell_override: crate::shell::Shell,
|
||||
) -> CodexResult<NewThread> {
|
||||
let environments = default_thread_environment_selections(
|
||||
@@ -645,7 +646,6 @@ impl ThreadManager {
|
||||
);
|
||||
Box::pin(self.state.spawn_thread(
|
||||
config,
|
||||
thread_store,
|
||||
InitialHistory::New,
|
||||
Arc::clone(&self.state.auth_manager),
|
||||
self.agent_control(),
|
||||
@@ -662,7 +662,6 @@ impl ThreadManager {
|
||||
pub(crate) async fn resume_thread_from_rollout_with_user_shell_override_for_tests(
|
||||
&self,
|
||||
config: Config,
|
||||
thread_store: Arc<dyn ThreadStore>,
|
||||
rollout_path: PathBuf,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
user_shell_override: crate::shell::Shell,
|
||||
@@ -674,7 +673,6 @@ impl ThreadManager {
|
||||
);
|
||||
Box::pin(self.state.spawn_thread(
|
||||
config,
|
||||
thread_store,
|
||||
initial_history,
|
||||
auth_manager,
|
||||
self.agent_control(),
|
||||
@@ -754,7 +752,6 @@ impl ThreadManager {
|
||||
&self,
|
||||
snapshot: S,
|
||||
config: Config,
|
||||
thread_store: Arc<dyn ThreadStore>,
|
||||
path: PathBuf,
|
||||
persist_extended_history: bool,
|
||||
parent_trace: Option<W3cTraceContext>,
|
||||
@@ -767,7 +764,6 @@ impl ThreadManager {
|
||||
self.fork_thread_from_history(
|
||||
snapshot,
|
||||
config,
|
||||
thread_store,
|
||||
history,
|
||||
persist_extended_history,
|
||||
parent_trace,
|
||||
@@ -780,7 +776,6 @@ impl ThreadManager {
|
||||
&self,
|
||||
snapshot: S,
|
||||
config: Config,
|
||||
thread_store: Arc<dyn ThreadStore>,
|
||||
history: InitialHistory,
|
||||
persist_extended_history: bool,
|
||||
parent_trace: Option<W3cTraceContext>,
|
||||
@@ -791,7 +786,6 @@ impl ThreadManager {
|
||||
self.fork_thread_with_initial_history(
|
||||
snapshot.into(),
|
||||
config,
|
||||
thread_store,
|
||||
history,
|
||||
persist_extended_history,
|
||||
parent_trace,
|
||||
@@ -803,7 +797,6 @@ impl ThreadManager {
|
||||
&self,
|
||||
snapshot: ForkSnapshot,
|
||||
config: Config,
|
||||
thread_store: Arc<dyn ThreadStore>,
|
||||
history: InitialHistory,
|
||||
persist_extended_history: bool,
|
||||
parent_trace: Option<W3cTraceContext>,
|
||||
@@ -816,7 +809,6 @@ impl ThreadManager {
|
||||
);
|
||||
Box::pin(self.state.spawn_thread(
|
||||
config,
|
||||
thread_store,
|
||||
history,
|
||||
Arc::clone(&self.state.auth_manager),
|
||||
self.agent_control(),
|
||||
@@ -865,6 +857,31 @@ impl ThreadManagerState {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn read_stored_thread(
|
||||
&self,
|
||||
params: ReadThreadParams,
|
||||
) -> CodexResult<StoredThread> {
|
||||
let thread_id = params.thread_id;
|
||||
self.thread_store
|
||||
.read_thread(params)
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
ThreadStoreError::ThreadNotFound { thread_id } => {
|
||||
CodexErr::ThreadNotFound(thread_id)
|
||||
}
|
||||
ThreadStoreError::InvalidRequest { message } => {
|
||||
if message.starts_with("no rollout found for thread id ") {
|
||||
CodexErr::ThreadNotFound(thread_id)
|
||||
} else {
|
||||
CodexErr::Fatal(format!(
|
||||
"failed to read stored thread {thread_id}: invalid thread-store request: {message}"
|
||||
))
|
||||
}
|
||||
}
|
||||
err => CodexErr::Fatal(format!("failed to read stored thread {thread_id}: {err}")),
|
||||
})
|
||||
}
|
||||
|
||||
/// Send an operation to a thread by ID.
|
||||
pub(crate) async fn send_op(&self, thread_id: ThreadId, op: Op) -> CodexResult<String> {
|
||||
let thread = self.get_thread(thread_id).await?;
|
||||
@@ -896,12 +913,10 @@ impl ThreadManagerState {
|
||||
pub(crate) async fn spawn_new_thread(
|
||||
&self,
|
||||
config: Config,
|
||||
thread_store: Arc<dyn ThreadStore>,
|
||||
agent_control: AgentControl,
|
||||
) -> CodexResult<NewThread> {
|
||||
Box::pin(self.spawn_new_thread_with_source(
|
||||
config,
|
||||
thread_store,
|
||||
agent_control,
|
||||
self.session_source.clone(),
|
||||
/*persist_extended_history*/ false,
|
||||
@@ -917,7 +932,6 @@ impl ThreadManagerState {
|
||||
pub(crate) async fn spawn_new_thread_with_source(
|
||||
&self,
|
||||
config: Config,
|
||||
thread_store: Arc<dyn ThreadStore>,
|
||||
agent_control: AgentControl,
|
||||
session_source: SessionSource,
|
||||
persist_extended_history: bool,
|
||||
@@ -931,7 +945,6 @@ impl ThreadManagerState {
|
||||
});
|
||||
Box::pin(self.spawn_thread_with_source(
|
||||
config,
|
||||
thread_store,
|
||||
InitialHistory::New,
|
||||
Arc::clone(&self.auth_manager),
|
||||
agent_control,
|
||||
@@ -948,25 +961,22 @@ impl ThreadManagerState {
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn resume_thread_from_rollout_with_source(
|
||||
pub(crate) async fn resume_thread_with_history_with_source(
|
||||
&self,
|
||||
options: ResumeThreadFromRolloutOptions,
|
||||
options: ResumeThreadWithHistoryOptions,
|
||||
) -> CodexResult<NewThread> {
|
||||
let ResumeThreadFromRolloutOptions {
|
||||
let ResumeThreadWithHistoryOptions {
|
||||
config,
|
||||
thread_store,
|
||||
rollout_path,
|
||||
initial_history,
|
||||
agent_control,
|
||||
session_source,
|
||||
inherited_shell_snapshot,
|
||||
inherited_exec_policy,
|
||||
} = options;
|
||||
let initial_history = RolloutRecorder::get_rollout_history(&rollout_path).await?;
|
||||
let environments =
|
||||
default_thread_environment_selections(self.environment_manager.as_ref(), &config.cwd);
|
||||
Box::pin(self.spawn_thread_with_source(
|
||||
config,
|
||||
thread_store,
|
||||
initial_history,
|
||||
Arc::clone(&self.auth_manager),
|
||||
agent_control,
|
||||
@@ -987,7 +997,6 @@ impl ThreadManagerState {
|
||||
pub(crate) async fn fork_thread_with_source(
|
||||
&self,
|
||||
config: Config,
|
||||
thread_store: Arc<dyn ThreadStore>,
|
||||
initial_history: InitialHistory,
|
||||
agent_control: AgentControl,
|
||||
session_source: SessionSource,
|
||||
@@ -1001,7 +1010,6 @@ impl ThreadManagerState {
|
||||
});
|
||||
Box::pin(self.spawn_thread_with_source(
|
||||
config,
|
||||
thread_store,
|
||||
initial_history,
|
||||
Arc::clone(&self.auth_manager),
|
||||
agent_control,
|
||||
@@ -1023,7 +1031,6 @@ impl ThreadManagerState {
|
||||
pub(crate) async fn spawn_thread(
|
||||
&self,
|
||||
config: Config,
|
||||
thread_store: Arc<dyn ThreadStore>,
|
||||
initial_history: InitialHistory,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
agent_control: AgentControl,
|
||||
@@ -1036,7 +1043,6 @@ impl ThreadManagerState {
|
||||
) -> CodexResult<NewThread> {
|
||||
Box::pin(self.spawn_thread_with_source(
|
||||
config,
|
||||
thread_store,
|
||||
initial_history,
|
||||
auth_manager,
|
||||
agent_control,
|
||||
@@ -1057,7 +1063,6 @@ impl ThreadManagerState {
|
||||
pub(crate) async fn spawn_thread_with_source(
|
||||
&self,
|
||||
config: Config,
|
||||
thread_store: Arc<dyn ThreadStore>,
|
||||
initial_history: InitialHistory,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
agent_control: AgentControl,
|
||||
@@ -1072,6 +1077,27 @@ impl ThreadManagerState {
|
||||
user_shell_override: Option<crate::shell::Shell>,
|
||||
) -> CodexResult<NewThread> {
|
||||
let is_resumed_thread = matches!(&initial_history, InitialHistory::Resumed(_));
|
||||
if let InitialHistory::Resumed(resumed) = &initial_history {
|
||||
let mut threads = self.threads.write().await;
|
||||
if let Some(thread) = threads.get(&resumed.conversation_id).cloned() {
|
||||
if thread.is_running() {
|
||||
if let Some(requested_rollout_path) = resumed.rollout_path.as_deref()
|
||||
&& thread.rollout_path().as_deref() != Some(requested_rollout_path)
|
||||
{
|
||||
return Err(CodexErr::InvalidRequest(format!(
|
||||
"thread {} is already running with a different rollout path",
|
||||
resumed.conversation_id
|
||||
)));
|
||||
}
|
||||
return Ok(NewThread {
|
||||
thread_id: resumed.conversation_id,
|
||||
session_configured: thread.session_configured(),
|
||||
thread,
|
||||
});
|
||||
}
|
||||
threads.remove(&resumed.conversation_id);
|
||||
}
|
||||
}
|
||||
let environment =
|
||||
selected_primary_environment(self.environment_manager.as_ref(), &environments)?;
|
||||
let watch_registration = match environment.as_ref() {
|
||||
@@ -1115,7 +1141,7 @@ impl ThreadManagerState {
|
||||
parent_trace,
|
||||
environments,
|
||||
analytics_events_client: self.analytics_events_client.clone(),
|
||||
thread_store,
|
||||
thread_store: Arc::clone(&self.thread_store),
|
||||
})
|
||||
.await?;
|
||||
let new_thread = self
|
||||
@@ -1147,20 +1173,31 @@ impl ThreadManagerState {
|
||||
}
|
||||
};
|
||||
|
||||
let thread = Arc::new(CodexThread::new(
|
||||
codex,
|
||||
session_configured.rollout_path.clone(),
|
||||
session_source,
|
||||
watch_registration,
|
||||
));
|
||||
let mut threads = self.threads.write().await;
|
||||
threads.insert(thread_id, thread.clone());
|
||||
{
|
||||
let mut threads = self.threads.write().await;
|
||||
if let std::collections::hash_map::Entry::Vacant(e) = threads.entry(thread_id) {
|
||||
let thread = Arc::new(CodexThread::new(
|
||||
codex,
|
||||
session_configured.clone(),
|
||||
session_configured.rollout_path.clone(),
|
||||
session_source,
|
||||
watch_registration,
|
||||
));
|
||||
e.insert(thread.clone());
|
||||
return Ok(NewThread {
|
||||
thread_id,
|
||||
thread,
|
||||
session_configured,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(NewThread {
|
||||
thread_id,
|
||||
thread,
|
||||
session_configured,
|
||||
})
|
||||
if let Err(err) = codex.shutdown_and_wait().await {
|
||||
warn!("failed to shut down duplicate thread {thread_id}: {err}");
|
||||
}
|
||||
Err(CodexErr::InvalidRequest(format!(
|
||||
"thread {thread_id} is already running"
|
||||
)))
|
||||
}
|
||||
|
||||
pub(crate) fn notify_thread_created(&self, thread_id: ThreadId) {
|
||||
|
||||
@@ -161,8 +161,7 @@ fn fork_thread_accepts_legacy_usize_snapshot_argument() {
|
||||
) {
|
||||
let _future = manager.fork_thread(
|
||||
usize::MAX,
|
||||
config.clone(),
|
||||
thread_store_from_config(&config),
|
||||
config,
|
||||
path,
|
||||
/*persist_extended_history*/ false,
|
||||
/*parent_trace*/ None,
|
||||
@@ -263,12 +262,12 @@ async fn shutdown_all_threads_bounded_submits_shutdown_to_every_thread() {
|
||||
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
||||
);
|
||||
let thread_1 = manager
|
||||
.start_thread(config.clone(), thread_store_from_config(&config))
|
||||
.start_thread(config.clone())
|
||||
.await
|
||||
.expect("start first thread")
|
||||
.thread_id;
|
||||
let thread_2 = manager
|
||||
.start_thread(config.clone(), thread_store_from_config(&config))
|
||||
.start_thread(config.clone())
|
||||
.await
|
||||
.expect("start second thread")
|
||||
.thread_id;
|
||||
@@ -314,7 +313,6 @@ async fn start_thread_accepts_explicit_environment_when_default_environment_is_d
|
||||
|
||||
let thread = manager
|
||||
.start_thread_with_options(StartThreadOptions {
|
||||
thread_store: thread_store_from_config(&config),
|
||||
config: config.clone(),
|
||||
initial_history: InitialHistory::New,
|
||||
session_source: None,
|
||||
@@ -347,10 +345,8 @@ async fn start_thread_keeps_internal_threads_hidden_from_normal_lookups() {
|
||||
config.codex_home.to_path_buf(),
|
||||
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
||||
);
|
||||
let thread_store = thread_store_from_config(&config);
|
||||
let thread = manager
|
||||
.start_thread_with_options(StartThreadOptions {
|
||||
thread_store,
|
||||
config,
|
||||
initial_history: InitialHistory::New,
|
||||
session_source: Some(SessionSource::Internal(
|
||||
@@ -393,6 +389,7 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() {
|
||||
SessionSource::Exec,
|
||||
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
||||
/*analytics_events_client*/ None,
|
||||
thread_store_from_config(&config),
|
||||
);
|
||||
let selected_cwd =
|
||||
AbsolutePathBuf::try_from(config.cwd.as_path().join("selected")).expect("absolute path");
|
||||
@@ -401,11 +398,8 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() {
|
||||
cwd: selected_cwd.clone(),
|
||||
}];
|
||||
let default_cwd = config.cwd.clone();
|
||||
let thread_store = thread_store_from_config(&config);
|
||||
|
||||
let source = manager
|
||||
.start_thread_with_options(StartThreadOptions {
|
||||
thread_store: Arc::clone(&thread_store),
|
||||
config: config.clone(),
|
||||
initial_history: InitialHistory::New,
|
||||
session_source: None,
|
||||
@@ -437,7 +431,6 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() {
|
||||
let resumed = manager
|
||||
.resume_thread_from_rollout(
|
||||
config.clone(),
|
||||
Arc::clone(&thread_store),
|
||||
rollout_path.clone(),
|
||||
auth_manager,
|
||||
/*parent_trace*/ None,
|
||||
@@ -459,7 +452,6 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() {
|
||||
.fork_thread(
|
||||
ForkSnapshot::Interrupted,
|
||||
config,
|
||||
thread_store,
|
||||
rollout_path,
|
||||
/*persist_extended_history*/ false,
|
||||
/*parent_trace*/ None,
|
||||
@@ -478,6 +470,117 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() {
|
||||
assert_ne!(forked_turn.environments[0].cwd, selected_cwd);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resume_active_thread_from_rollout_returns_running_thread() {
|
||||
let temp_dir = tempdir().expect("tempdir");
|
||||
let mut config = test_config().await;
|
||||
config.codex_home = temp_dir.path().join("codex-home").abs();
|
||||
config.cwd = config.codex_home.abs();
|
||||
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
||||
|
||||
let auth_manager =
|
||||
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
||||
let manager = ThreadManager::new(
|
||||
&config,
|
||||
auth_manager.clone(),
|
||||
SessionSource::Exec,
|
||||
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
||||
/*analytics_events_client*/ None,
|
||||
thread_store_from_config(&config),
|
||||
);
|
||||
|
||||
let source = manager
|
||||
.start_thread(config.clone())
|
||||
.await
|
||||
.expect("start source thread");
|
||||
source.thread.ensure_rollout_materialized().await;
|
||||
source
|
||||
.thread
|
||||
.flush_rollout()
|
||||
.await
|
||||
.expect("flush source rollout");
|
||||
let rollout_path = source
|
||||
.thread
|
||||
.rollout_path()
|
||||
.expect("source rollout path should exist");
|
||||
|
||||
let resumed = manager
|
||||
.resume_thread_from_rollout(
|
||||
config,
|
||||
rollout_path,
|
||||
auth_manager,
|
||||
/*parent_trace*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("resume active source thread");
|
||||
assert_eq!(resumed.thread_id, source.thread_id);
|
||||
assert!(Arc::ptr_eq(&resumed.thread, &source.thread));
|
||||
|
||||
source
|
||||
.thread
|
||||
.shutdown_and_wait()
|
||||
.await
|
||||
.expect("shutdown source thread");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resume_stopped_thread_from_rollout_spawns_new_thread() {
|
||||
let temp_dir = tempdir().expect("tempdir");
|
||||
let mut config = test_config().await;
|
||||
config.codex_home = temp_dir.path().join("codex-home").abs();
|
||||
config.cwd = config.codex_home.abs();
|
||||
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
||||
|
||||
let auth_manager =
|
||||
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
||||
let manager = ThreadManager::new(
|
||||
&config,
|
||||
auth_manager.clone(),
|
||||
SessionSource::Exec,
|
||||
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
||||
/*analytics_events_client*/ None,
|
||||
thread_store_from_config(&config),
|
||||
);
|
||||
|
||||
let source = manager
|
||||
.start_thread(config.clone())
|
||||
.await
|
||||
.expect("start source thread");
|
||||
source.thread.ensure_rollout_materialized().await;
|
||||
source
|
||||
.thread
|
||||
.flush_rollout()
|
||||
.await
|
||||
.expect("flush source rollout");
|
||||
let rollout_path = source
|
||||
.thread
|
||||
.rollout_path()
|
||||
.expect("source rollout path should exist");
|
||||
source
|
||||
.thread
|
||||
.shutdown_and_wait()
|
||||
.await
|
||||
.expect("shutdown source thread");
|
||||
|
||||
let resumed = manager
|
||||
.resume_thread_from_rollout(
|
||||
config,
|
||||
rollout_path,
|
||||
auth_manager,
|
||||
/*parent_trace*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("resume stopped source thread");
|
||||
assert_eq!(resumed.thread_id, source.thread_id);
|
||||
assert!(!Arc::ptr_eq(&resumed.thread, &source.thread));
|
||||
|
||||
resumed
|
||||
.thread
|
||||
.shutdown_and_wait()
|
||||
.await
|
||||
.expect("shutdown resumed thread");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn new_uses_active_provider_for_model_refresh() {
|
||||
let server = MockServer::start().await;
|
||||
@@ -499,6 +602,7 @@ async fn new_uses_active_provider_for_model_refresh() {
|
||||
SessionSource::Exec,
|
||||
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
||||
/*analytics_events_client*/ None,
|
||||
thread_store_from_config(&config),
|
||||
);
|
||||
|
||||
let _ = manager.list_models(RefreshStrategy::Online).await;
|
||||
@@ -709,12 +813,12 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor
|
||||
SessionSource::Exec,
|
||||
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
||||
/*analytics_events_client*/ None,
|
||||
thread_store_from_config(&config),
|
||||
);
|
||||
|
||||
let source = manager
|
||||
.resume_thread_with_history(
|
||||
config.clone(),
|
||||
thread_store_from_config(&config),
|
||||
InitialHistory::Forked(vec![
|
||||
RolloutItem::ResponseItem(user_msg("hello")),
|
||||
RolloutItem::ResponseItem(assistant_msg("partial")),
|
||||
@@ -741,7 +845,6 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor
|
||||
.fork_thread(
|
||||
ForkSnapshot::Interrupted,
|
||||
config.clone(),
|
||||
thread_store_from_config(&config),
|
||||
source_path,
|
||||
/*persist_extended_history*/ false,
|
||||
/*parent_trace*/ None,
|
||||
@@ -812,12 +915,12 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() {
|
||||
SessionSource::Exec,
|
||||
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
||||
/*analytics_events_client*/ None,
|
||||
thread_store_from_config(&config),
|
||||
);
|
||||
|
||||
let source = manager
|
||||
.resume_thread_with_history(
|
||||
config.clone(),
|
||||
thread_store_from_config(&config),
|
||||
InitialHistory::Forked(vec![
|
||||
RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-explicit".to_string(),
|
||||
@@ -855,7 +958,6 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() {
|
||||
.fork_thread(
|
||||
ForkSnapshot::Interrupted,
|
||||
config.clone(),
|
||||
thread_store_from_config(&config),
|
||||
source_path,
|
||||
/*persist_extended_history*/ false,
|
||||
/*parent_trace*/ None,
|
||||
@@ -904,12 +1006,12 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_
|
||||
SessionSource::Exec,
|
||||
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
||||
/*analytics_events_client*/ None,
|
||||
thread_store_from_config(&config),
|
||||
);
|
||||
|
||||
let source = manager
|
||||
.resume_thread_with_history(
|
||||
config.clone(),
|
||||
thread_store_from_config(&config),
|
||||
InitialHistory::Forked(vec![
|
||||
RolloutItem::ResponseItem(user_msg("hello")),
|
||||
RolloutItem::ResponseItem(assistant_msg("partial")),
|
||||
@@ -934,7 +1036,6 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_
|
||||
.fork_thread(
|
||||
ForkSnapshot::Interrupted,
|
||||
config.clone(),
|
||||
thread_store_from_config(&config),
|
||||
source_path,
|
||||
/*persist_extended_history*/ false,
|
||||
/*parent_trace*/ None,
|
||||
@@ -975,7 +1076,6 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_
|
||||
.fork_thread(
|
||||
ForkSnapshot::Interrupted,
|
||||
config.clone(),
|
||||
thread_store_from_config(&config),
|
||||
forked_path,
|
||||
/*persist_extended_history*/ false,
|
||||
/*parent_trace*/ None,
|
||||
@@ -1042,12 +1142,12 @@ async fn resumed_thread_activates_paused_goal_and_continues_on_request() -> anyh
|
||||
SessionSource::Exec,
|
||||
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
||||
/*analytics_events_client*/ None,
|
||||
thread_store_from_config(&config),
|
||||
);
|
||||
|
||||
let source = manager
|
||||
.resume_thread_with_history(
|
||||
config.clone(),
|
||||
thread_store_from_config(&config),
|
||||
InitialHistory::Forked(vec![RolloutItem::ResponseItem(user_msg("keep working"))]),
|
||||
auth_manager.clone(),
|
||||
/*persist_extended_history*/ false,
|
||||
@@ -1072,12 +1172,12 @@ async fn resumed_thread_activates_paused_goal_and_continues_on_request() -> anyh
|
||||
/*token_budget*/ None,
|
||||
)
|
||||
.await?;
|
||||
source.thread.shutdown_and_wait().await?;
|
||||
manager.remove_thread(&source.thread_id).await;
|
||||
|
||||
let resumed = manager
|
||||
.resume_thread_from_rollout(
|
||||
config.clone(),
|
||||
thread_store_from_config(&config),
|
||||
source_path,
|
||||
auth_manager,
|
||||
/*parent_trace*/ None,
|
||||
|
||||
@@ -6,6 +6,8 @@ use crate::tools::sandboxing::ToolError;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::error::SandboxErr;
|
||||
use codex_protocol::exec_output::ExecToolCallOutput;
|
||||
use codex_protocol::items::FileChangeItem;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::parse_command::ParsedCommand;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::ExecCommandBeginEvent;
|
||||
@@ -13,8 +15,6 @@ use codex_protocol::protocol::ExecCommandEndEvent;
|
||||
use codex_protocol::protocol::ExecCommandSource;
|
||||
use codex_protocol::protocol::ExecCommandStatus;
|
||||
use codex_protocol::protocol::FileChange;
|
||||
use codex_protocol::protocol::PatchApplyBeginEvent;
|
||||
use codex_protocol::protocol::PatchApplyEndEvent;
|
||||
use codex_protocol::protocol::PatchApplyStatus;
|
||||
use codex_protocol::protocol::TurnDiffEvent;
|
||||
use codex_shell_command::parse_command::parse_command;
|
||||
@@ -183,13 +183,15 @@ impl ToolEmitter {
|
||||
guard.on_patch_begin(changes);
|
||||
}
|
||||
ctx.session
|
||||
.send_event(
|
||||
.emit_turn_item_started(
|
||||
ctx.turn,
|
||||
EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
|
||||
call_id: ctx.call_id.to_string(),
|
||||
turn_id: ctx.turn.sub_id.clone(),
|
||||
auto_approved: *auto_approved,
|
||||
&TurnItem::FileChange(FileChangeItem {
|
||||
id: ctx.call_id.to_string(),
|
||||
changes: changes.clone(),
|
||||
status: None,
|
||||
auto_approved: Some(*auto_approved),
|
||||
stdout: None,
|
||||
stderr: None,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@@ -200,7 +202,6 @@ impl ToolEmitter {
|
||||
changes.clone(),
|
||||
output.stdout.text.clone(),
|
||||
output.stderr.text.clone(),
|
||||
output.exit_code == 0,
|
||||
if output.exit_code == 0 {
|
||||
PatchApplyStatus::Completed
|
||||
} else {
|
||||
@@ -218,7 +219,6 @@ impl ToolEmitter {
|
||||
changes.clone(),
|
||||
output.stdout.text.clone(),
|
||||
output.stderr.text.clone(),
|
||||
output.exit_code == 0,
|
||||
if output.exit_code == 0 {
|
||||
PatchApplyStatus::Completed
|
||||
} else {
|
||||
@@ -236,7 +236,6 @@ impl ToolEmitter {
|
||||
changes.clone(),
|
||||
String::new(),
|
||||
(*message).to_string(),
|
||||
/*success*/ false,
|
||||
PatchApplyStatus::Failed,
|
||||
)
|
||||
.await;
|
||||
@@ -250,7 +249,6 @@ impl ToolEmitter {
|
||||
changes.clone(),
|
||||
String::new(),
|
||||
(*message).to_string(),
|
||||
/*success*/ false,
|
||||
PatchApplyStatus::Declined,
|
||||
)
|
||||
.await;
|
||||
@@ -496,20 +494,18 @@ async fn emit_patch_end(
|
||||
changes: HashMap<PathBuf, FileChange>,
|
||||
stdout: String,
|
||||
stderr: String,
|
||||
success: bool,
|
||||
status: PatchApplyStatus,
|
||||
) {
|
||||
ctx.session
|
||||
.send_event(
|
||||
.emit_turn_item_completed(
|
||||
ctx.turn,
|
||||
EventMsg::PatchApplyEnd(PatchApplyEndEvent {
|
||||
call_id: ctx.call_id.to_string(),
|
||||
turn_id: ctx.turn.sub_id.clone(),
|
||||
stdout,
|
||||
stderr,
|
||||
success,
|
||||
TurnItem::FileChange(FileChangeItem {
|
||||
id: ctx.call_id.to_string(),
|
||||
changes,
|
||||
status,
|
||||
status: Some(status),
|
||||
auto_approved: None,
|
||||
stdout: Some(stdout),
|
||||
stderr: Some(stderr),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -5,7 +5,6 @@ use crate::config::DEFAULT_AGENT_MAX_DEPTH;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::session::tests::make_session_and_context;
|
||||
use crate::session_prefix::format_subagent_notification_message;
|
||||
use crate::thread_manager::thread_store_from_config;
|
||||
use crate::tools::context::ToolOutput;
|
||||
use crate::tools::handlers::multi_agents_v2::CloseAgentHandler as CloseAgentHandlerV2;
|
||||
use crate::tools::handlers::multi_agents_v2::FollowupTaskHandler as FollowupTaskHandlerV2;
|
||||
@@ -297,10 +296,7 @@ async fn spawn_agent_fork_context_rejects_agent_type_override() {
|
||||
let role_name = install_role_with_model_override(&mut turn).await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread(
|
||||
(*turn.config).clone(),
|
||||
thread_store_from_config(turn.config.as_ref()),
|
||||
)
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
@@ -332,10 +328,7 @@ async fn spawn_agent_fork_context_rejects_child_model_overrides() {
|
||||
let (mut session, turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread(
|
||||
(*turn.config).clone(),
|
||||
thread_store_from_config(turn.config.as_ref()),
|
||||
)
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
@@ -370,10 +363,7 @@ async fn multi_agent_v2_spawn_fork_turns_all_rejects_agent_type_override() {
|
||||
let role_name = install_role_with_model_override(&mut turn).await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread(
|
||||
(*turn.config).clone(),
|
||||
thread_store_from_config(turn.config.as_ref()),
|
||||
)
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
@@ -416,10 +406,7 @@ async fn multi_agent_v2_spawn_defaults_to_full_fork_and_rejects_child_model_over
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread(
|
||||
(*turn.config).clone(),
|
||||
thread_store_from_config(turn.config.as_ref()),
|
||||
)
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
@@ -460,10 +447,7 @@ async fn multi_agent_v2_spawn_partial_fork_turns_allows_agent_type_override() {
|
||||
let role_name = install_role_with_model_override(&mut turn).await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread(
|
||||
(*turn.config).clone(),
|
||||
thread_store_from_config(turn.config.as_ref()),
|
||||
)
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
@@ -546,10 +530,7 @@ async fn multi_agent_v2_spawn_requires_task_name() {
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread(
|
||||
(*turn.config).clone(),
|
||||
thread_store_from_config(turn.config.as_ref()),
|
||||
)
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
@@ -583,10 +564,7 @@ async fn multi_agent_v2_spawn_rejects_legacy_items_field() {
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread(
|
||||
(*turn.config).clone(),
|
||||
thread_store_from_config(turn.config.as_ref()),
|
||||
)
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
@@ -646,10 +624,7 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread(
|
||||
(*turn.config).clone(),
|
||||
thread_store_from_config(turn.config.as_ref()),
|
||||
)
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
@@ -746,10 +721,7 @@ async fn multi_agent_v2_spawn_rejects_legacy_fork_context() {
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread(
|
||||
(*turn.config).clone(),
|
||||
thread_store_from_config(turn.config.as_ref()),
|
||||
)
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
@@ -788,10 +760,7 @@ async fn multi_agent_v2_spawn_rejects_invalid_fork_turns_string() {
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread(
|
||||
(*turn.config).clone(),
|
||||
thread_store_from_config(turn.config.as_ref()),
|
||||
)
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
@@ -830,10 +799,7 @@ async fn multi_agent_v2_spawn_rejects_zero_fork_turns() {
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread(
|
||||
(*turn.config).clone(),
|
||||
thread_store_from_config(turn.config.as_ref()),
|
||||
)
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
@@ -872,10 +838,7 @@ async fn multi_agent_v2_send_message_accepts_root_target_from_child() {
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread(
|
||||
(*turn.config).clone(),
|
||||
thread_store_from_config(turn.config.as_ref()),
|
||||
)
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
@@ -951,10 +914,7 @@ async fn multi_agent_v2_followup_task_rejects_root_target_from_child() {
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread(
|
||||
(*turn.config).clone(),
|
||||
thread_store_from_config(turn.config.as_ref()),
|
||||
)
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
@@ -1035,10 +995,7 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread(
|
||||
(*turn.config).clone(),
|
||||
thread_store_from_config(turn.config.as_ref()),
|
||||
)
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
@@ -1132,10 +1089,7 @@ async fn multi_agent_v2_list_agents_filters_by_relative_path_prefix() {
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread(
|
||||
(*turn.config).clone(),
|
||||
thread_store_from_config(turn.config.as_ref()),
|
||||
)
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
@@ -1222,10 +1176,7 @@ async fn multi_agent_v2_list_agents_omits_closed_agents() {
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread(
|
||||
(*turn.config).clone(),
|
||||
thread_store_from_config(turn.config.as_ref()),
|
||||
)
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
@@ -1289,10 +1240,7 @@ async fn multi_agent_v2_send_message_rejects_legacy_items_field() {
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread(
|
||||
(*turn.config).clone(),
|
||||
thread_store_from_config(turn.config.as_ref()),
|
||||
)
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
@@ -1348,10 +1296,7 @@ async fn multi_agent_v2_send_message_rejects_interrupt_parameter() {
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread(
|
||||
(*turn.config).clone(),
|
||||
thread_store_from_config(turn.config.as_ref()),
|
||||
)
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
@@ -1424,10 +1369,7 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn()
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread(
|
||||
(*turn.config).clone(),
|
||||
thread_store_from_config(turn.config.as_ref()),
|
||||
)
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
@@ -1562,10 +1504,7 @@ async fn multi_agent_v2_followup_task_rejects_legacy_items_field() {
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread(
|
||||
(*turn.config).clone(),
|
||||
thread_store_from_config(turn.config.as_ref()),
|
||||
)
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
@@ -1618,10 +1557,7 @@ async fn multi_agent_v2_interrupted_turn_does_not_notify_parent() {
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread(
|
||||
(*turn.config).clone(),
|
||||
thread_store_from_config(turn.config.as_ref()),
|
||||
)
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
@@ -1698,10 +1634,7 @@ async fn multi_agent_v2_spawn_omits_agent_id_when_named() {
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread(
|
||||
(*turn.config).clone(),
|
||||
thread_store_from_config(turn.config.as_ref()),
|
||||
)
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
@@ -1740,10 +1673,7 @@ async fn multi_agent_v2_spawn_surfaces_task_name_validation_errors() {
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread(
|
||||
(*turn.config).clone(),
|
||||
thread_store_from_config(turn.config.as_ref()),
|
||||
)
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
@@ -1957,7 +1887,7 @@ async fn multi_agent_v2_spawn_agent_ignores_configured_max_depth() {
|
||||
.enable(Feature::MultiAgentV2)
|
||||
.expect("test config should allow feature update");
|
||||
let root = manager
|
||||
.start_thread(config.clone(), thread_store_from_config(&config))
|
||||
.start_thread(config.clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
@@ -2082,7 +2012,7 @@ async fn send_input_interrupts_before_prompt() {
|
||||
session.services.agent_control = manager.agent_control();
|
||||
let config = turn.config.as_ref().clone();
|
||||
let thread = manager
|
||||
.start_thread(config.clone(), thread_store_from_config(&config))
|
||||
.start_thread(config.clone())
|
||||
.await
|
||||
.expect("start thread");
|
||||
let agent_id = thread.thread_id;
|
||||
@@ -2124,7 +2054,7 @@ async fn send_input_accepts_structured_items() {
|
||||
session.services.agent_control = manager.agent_control();
|
||||
let config = turn.config.as_ref().clone();
|
||||
let thread = manager
|
||||
.start_thread(config.clone(), thread_store_from_config(&config))
|
||||
.start_thread(config.clone())
|
||||
.await
|
||||
.expect("start thread");
|
||||
let agent_id = thread.thread_id;
|
||||
@@ -2219,7 +2149,7 @@ async fn resume_agent_noops_for_active_agent() {
|
||||
session.services.agent_control = manager.agent_control();
|
||||
let config = turn.config.as_ref().clone();
|
||||
let thread = manager
|
||||
.start_thread(config.clone(), thread_store_from_config(&config))
|
||||
.start_thread(config.clone())
|
||||
.await
|
||||
.expect("start thread");
|
||||
let agent_id = thread.thread_id;
|
||||
@@ -2260,7 +2190,6 @@ async fn resume_agent_restores_closed_agent_and_accepts_send_input() {
|
||||
let thread = manager
|
||||
.resume_thread_with_history(
|
||||
config.clone(),
|
||||
thread_store_from_config(&config),
|
||||
InitialHistory::Forked(vec![RolloutItem::ResponseItem(ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
@@ -2425,10 +2354,7 @@ async fn multi_agent_v2_wait_agent_accepts_timeout_only_argument() {
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread(
|
||||
(*turn.config).clone(),
|
||||
thread_store_from_config(turn.config.as_ref()),
|
||||
)
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
@@ -2605,7 +2531,7 @@ async fn wait_agent_times_out_when_status_is_not_final() {
|
||||
session.services.agent_control = manager.agent_control();
|
||||
let config = turn.config.as_ref().clone();
|
||||
let thread = manager
|
||||
.start_thread(config.clone(), thread_store_from_config(&config))
|
||||
.start_thread(config.clone())
|
||||
.await
|
||||
.expect("start thread");
|
||||
let agent_id = thread.thread_id;
|
||||
@@ -2648,7 +2574,7 @@ async fn wait_agent_clamps_short_timeouts_to_minimum() {
|
||||
session.services.agent_control = manager.agent_control();
|
||||
let config = turn.config.as_ref().clone();
|
||||
let thread = manager
|
||||
.start_thread(config.clone(), thread_store_from_config(&config))
|
||||
.start_thread(config.clone())
|
||||
.await
|
||||
.expect("start thread");
|
||||
let agent_id = thread.thread_id;
|
||||
@@ -2686,7 +2612,7 @@ async fn wait_agent_returns_final_status_without_timeout() {
|
||||
session.services.agent_control = manager.agent_control();
|
||||
let config = turn.config.as_ref().clone();
|
||||
let thread = manager
|
||||
.start_thread(config.clone(), thread_store_from_config(&config))
|
||||
.start_thread(config.clone())
|
||||
.await
|
||||
.expect("start thread");
|
||||
let agent_id = thread.thread_id;
|
||||
@@ -2736,10 +2662,7 @@ async fn multi_agent_v2_wait_agent_returns_summary_for_mailbox_activity() {
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread(
|
||||
(*turn.config).clone(),
|
||||
thread_store_from_config(turn.config.as_ref()),
|
||||
)
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
@@ -2830,10 +2753,7 @@ async fn multi_agent_v2_wait_agent_returns_for_already_queued_mail() {
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread(
|
||||
(*turn.config).clone(),
|
||||
thread_store_from_config(turn.config.as_ref()),
|
||||
)
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
@@ -2911,10 +2831,7 @@ async fn multi_agent_v2_wait_agent_wakes_on_any_mailbox_notification() {
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread(
|
||||
(*turn.config).clone(),
|
||||
thread_store_from_config(turn.config.as_ref()),
|
||||
)
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
@@ -3002,10 +2919,7 @@ async fn multi_agent_v2_wait_agent_does_not_return_completed_content() {
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread(
|
||||
(*turn.config).clone(),
|
||||
thread_store_from_config(turn.config.as_ref()),
|
||||
)
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
@@ -3091,10 +3005,7 @@ async fn multi_agent_v2_close_agent_accepts_task_name_target() {
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread(
|
||||
(*turn.config).clone(),
|
||||
thread_store_from_config(turn.config.as_ref()),
|
||||
)
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
@@ -3153,10 +3064,7 @@ async fn multi_agent_v2_close_agent_rejects_root_target_and_id() {
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread(
|
||||
(*turn.config).clone(),
|
||||
thread_store_from_config(turn.config.as_ref()),
|
||||
)
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
@@ -3206,7 +3114,7 @@ async fn close_agent_submits_shutdown_and_returns_previous_status() {
|
||||
session.services.agent_control = manager.agent_control();
|
||||
let config = turn.config.as_ref().clone();
|
||||
let thread = manager
|
||||
.start_thread(config.clone(), thread_store_from_config(&config))
|
||||
.start_thread(config.clone())
|
||||
.await
|
||||
.expect("start thread");
|
||||
let agent_id = thread.thread_id;
|
||||
@@ -3250,7 +3158,7 @@ async fn tool_handlers_cascade_close_and_resume_and_keep_explicitly_closed_subtr
|
||||
.expect("test config should allow sqlite");
|
||||
|
||||
let parent = manager
|
||||
.start_thread(config.clone(), thread_store_from_config(&config))
|
||||
.start_thread(config.clone())
|
||||
.await
|
||||
.expect("parent thread should start");
|
||||
let parent_thread_id = parent.thread_id;
|
||||
@@ -3381,7 +3289,7 @@ async fn tool_handlers_cascade_close_and_resume_and_keep_explicitly_closed_subtr
|
||||
);
|
||||
|
||||
let operator = manager
|
||||
.start_thread(config.clone(), thread_store_from_config(&config))
|
||||
.start_thread(config.clone())
|
||||
.await
|
||||
.expect("operator thread should start");
|
||||
let operator_session = operator.thread.codex.session.clone();
|
||||
|
||||
@@ -25,4 +25,4 @@ Before deciding that the goal is achieved, perform a completion audit against th
|
||||
|
||||
Do not rely on intent, partial progress, elapsed effort, memory of earlier work, or a plausible final answer as proof of completion. Only mark the goal achieved when the audit shows that the objective has actually been achieved and no required work remains. If any requirement is missing, incomplete, or unverified, keep working instead of marking the goal complete. If the objective is achieved, call update_goal with status "complete" so usage accounting is preserved. Report the final elapsed time, and if the achieved goal has a token budget, report the final consumed token budget to the user after update_goal succeeds.
|
||||
|
||||
If the goal has not been achieved and cannot continue productively, explain the blocker or next required input to the user and wait for new input. Do not call update_goal unless the goal is complete. Do not mark a goal complete merely because the budget is nearly exhausted or because you are stopping work.
|
||||
Do not call update_goal unless the goal is complete. Do not mark a goal complete merely because the budget is nearly exhausted or because you are stopping work.
|
||||
|
||||
@@ -430,6 +430,7 @@ impl TestCodexBuilder {
|
||||
SessionSource::Exec,
|
||||
Arc::clone(&environment_manager),
|
||||
/*analytics_events_client*/ None,
|
||||
thread_store_from_config(&config),
|
||||
)
|
||||
} else {
|
||||
codex_core::test_support::thread_manager_with_models_provider_and_home(
|
||||
@@ -449,7 +450,6 @@ impl TestCodexBuilder {
|
||||
codex_core::test_support::resume_thread_from_rollout_with_user_shell_override(
|
||||
thread_manager.as_ref(),
|
||||
config.clone(),
|
||||
thread_store_from_config(&config),
|
||||
path,
|
||||
auth_manager,
|
||||
user_shell_override,
|
||||
@@ -461,7 +461,6 @@ impl TestCodexBuilder {
|
||||
let auth_manager = codex_core::test_support::auth_manager_from_auth(auth);
|
||||
Box::pin(thread_manager.resume_thread_from_rollout(
|
||||
config.clone(),
|
||||
thread_store_from_config(&config),
|
||||
path,
|
||||
auth_manager,
|
||||
/*parent_trace*/ None,
|
||||
@@ -473,18 +472,12 @@ impl TestCodexBuilder {
|
||||
codex_core::test_support::start_thread_with_user_shell_override(
|
||||
thread_manager.as_ref(),
|
||||
config.clone(),
|
||||
thread_store_from_config(&config),
|
||||
user_shell_override,
|
||||
),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
(None, None) => {
|
||||
Box::pin(
|
||||
thread_manager.start_thread(config.clone(), thread_store_from_config(&config)),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
(None, None) => Box::pin(thread_manager.start_thread(config.clone())).await?,
|
||||
};
|
||||
|
||||
Ok(TestCodex {
|
||||
|
||||
@@ -1107,9 +1107,10 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() {
|
||||
SessionSource::Exec,
|
||||
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
||||
/*analytics_events_client*/ None,
|
||||
thread_store_from_config(&config),
|
||||
);
|
||||
let NewThread { thread: codex, .. } = thread_manager
|
||||
.start_thread(config.clone(), thread_store_from_config(&config))
|
||||
.start_thread(config.clone())
|
||||
.await
|
||||
.expect("create new conversation");
|
||||
|
||||
|
||||
@@ -2556,7 +2556,6 @@ async fn code_mode_can_call_hidden_dynamic_tools() -> Result<()> {
|
||||
.thread_manager
|
||||
.start_thread_with_tools(
|
||||
base_test.config.clone(),
|
||||
codex_core::thread_store_from_config(&base_test.config),
|
||||
vec![DynamicToolSpec {
|
||||
namespace: Some("codex_app".to_string()),
|
||||
name: "hidden_dynamic_tool".to_string(),
|
||||
|
||||
@@ -445,7 +445,6 @@ async fn remote_compact_filters_deferred_dynamic_tools() -> Result<()> {
|
||||
.thread_manager
|
||||
.start_thread_with_tools(
|
||||
test.config.clone(),
|
||||
codex_core::thread_store_from_config(&test.config),
|
||||
dynamic_tools,
|
||||
/*persist_extended_history*/ false,
|
||||
)
|
||||
|
||||
@@ -149,6 +149,7 @@ async fn compact_resume_and_fork_preserve_model_history_view() {
|
||||
"compact+resume test expects base path {base_path:?} to exist",
|
||||
);
|
||||
|
||||
shutdown_conversation(&base).await;
|
||||
let resumed = resume_conversation(&manager, &config, base_path).await;
|
||||
user_turn(&resumed, "AFTER_RESUME").await;
|
||||
let resumed_path = fetch_conversation_path(&resumed);
|
||||
@@ -304,6 +305,7 @@ async fn compact_resume_after_second_compaction_preserves_history() -> Result<()
|
||||
"second compact test expects base path {base_path:?} to exist",
|
||||
);
|
||||
|
||||
shutdown_conversation(&base).await;
|
||||
let resumed = resume_conversation(&manager, &config, base_path).await;
|
||||
user_turn(&resumed, "AFTER_RESUME").await;
|
||||
let resumed_path = fetch_conversation_path(&resumed);
|
||||
@@ -323,6 +325,7 @@ async fn compact_resume_after_second_compaction_preserves_history() -> Result<()
|
||||
"second compact test expects forked path {forked_path:?} to exist",
|
||||
);
|
||||
|
||||
shutdown_conversation(&forked).await;
|
||||
let resumed_again = resume_conversation(&manager, &config, forked_path).await;
|
||||
user_turn(&resumed_again, AFTER_SECOND_RESUME).await;
|
||||
|
||||
@@ -815,6 +818,13 @@ fn fetch_conversation_path(conversation: &Arc<CodexThread>) -> std::path::PathBu
|
||||
conversation.rollout_path().expect("rollout path")
|
||||
}
|
||||
|
||||
async fn shutdown_conversation(conversation: &Arc<CodexThread>) {
|
||||
conversation
|
||||
.shutdown_and_wait()
|
||||
.await
|
||||
.expect("shutdown conversation");
|
||||
}
|
||||
|
||||
async fn resume_conversation(
|
||||
manager: &ThreadManager,
|
||||
config: &Config,
|
||||
@@ -825,7 +835,6 @@ async fn resume_conversation(
|
||||
);
|
||||
Box::pin(manager.resume_thread_from_rollout(
|
||||
config.clone(),
|
||||
codex_core::thread_store_from_config(config),
|
||||
path,
|
||||
auth_manager,
|
||||
/*parent_trace*/ None,
|
||||
@@ -845,7 +854,6 @@ async fn fork_thread(
|
||||
Box::pin(manager.fork_thread(
|
||||
nth_user_message,
|
||||
config.clone(),
|
||||
codex_core::thread_store_from_config(config),
|
||||
path,
|
||||
/*persist_extended_history*/ false,
|
||||
/*parent_trace*/ None,
|
||||
|
||||
@@ -100,7 +100,6 @@ async fn fork_thread_twice_drops_to_first_message() {
|
||||
.fork_thread(
|
||||
ForkSnapshot::TruncateBeforeNthUserMessage(1),
|
||||
config_for_fork.clone(),
|
||||
codex_core::thread_store_from_config(&config_for_fork),
|
||||
base_path.clone(),
|
||||
/*persist_extended_history*/ false,
|
||||
/*parent_trace*/ None,
|
||||
@@ -125,7 +124,6 @@ async fn fork_thread_twice_drops_to_first_message() {
|
||||
.fork_thread(
|
||||
ForkSnapshot::TruncateBeforeNthUserMessage(0),
|
||||
config_for_fork.clone(),
|
||||
codex_core::thread_store_from_config(&config_for_fork),
|
||||
fork1_path.clone(),
|
||||
/*persist_extended_history*/ false,
|
||||
/*parent_trace*/ None,
|
||||
@@ -194,7 +192,6 @@ async fn fork_thread_from_history_does_not_require_source_rollout_path() {
|
||||
.fork_thread_from_history(
|
||||
ForkSnapshot::Interrupted,
|
||||
test.config.clone(),
|
||||
codex_core::thread_store_from_config(&test.config),
|
||||
InitialHistory::Resumed(ResumedHistory {
|
||||
conversation_id: test.session_configured.session_id,
|
||||
history: source_items.clone(),
|
||||
|
||||
@@ -496,7 +496,6 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> {
|
||||
.fork_thread(
|
||||
ForkSnapshot::Interrupted,
|
||||
fork_config.clone(),
|
||||
codex_core::thread_store_from_config(&fork_config),
|
||||
rollout_path,
|
||||
/*persist_extended_history*/ false,
|
||||
/*parent_trace*/ None,
|
||||
|
||||
@@ -1671,7 +1671,6 @@ async fn conversation_startup_context_current_thread_selects_many_turns_by_budge
|
||||
.thread_manager
|
||||
.resume_thread_with_history(
|
||||
test.config.clone(),
|
||||
codex_core::thread_store_from_config(&test.config),
|
||||
InitialHistory::Forked(history),
|
||||
auth_manager_from_auth(CodexAuth::from_api_key("dummy")),
|
||||
/*persist_extended_history*/ false,
|
||||
|
||||
@@ -106,7 +106,6 @@ async fn emits_warning_when_resumed_model_differs() {
|
||||
} = thread_manager
|
||||
.resume_thread_with_history(
|
||||
config.clone(),
|
||||
codex_core::thread_store_from_config(&config),
|
||||
initial_history,
|
||||
auth_manager,
|
||||
/*persist_extended_history*/ false,
|
||||
|
||||
@@ -793,7 +793,6 @@ async fn tool_search_returns_deferred_dynamic_tool_and_routes_follow_up_call() -
|
||||
.thread_manager
|
||||
.start_thread_with_tools(
|
||||
base_test.config.clone(),
|
||||
codex_core::thread_store_from_config(&base_test.config),
|
||||
vec![dynamic_tool],
|
||||
/*persist_extended_history*/ false,
|
||||
)
|
||||
|
||||
@@ -246,10 +246,9 @@ async fn list_skills_skips_cwd_roots_when_environment_disabled() -> Result<()> {
|
||||
)?,
|
||||
)),
|
||||
/*analytics_events_client*/ None,
|
||||
thread_store_from_config(&config),
|
||||
);
|
||||
let new_thread = thread_manager
|
||||
.start_thread(config.clone(), thread_store_from_config(&config))
|
||||
.await?;
|
||||
let new_thread = thread_manager.start_thread(config.clone()).await?;
|
||||
let cwd = config.cwd.to_path_buf();
|
||||
|
||||
new_thread
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::fs;
|
||||
|
||||
use assert_matches::assert_matches;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::plan_tool::StepStatus;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
@@ -365,9 +366,30 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<()
|
||||
})
|
||||
.await?;
|
||||
|
||||
let mut saw_file_change_started = false;
|
||||
let mut saw_file_change_completed = false;
|
||||
let mut saw_patch_begin = false;
|
||||
let mut patch_end_success = None;
|
||||
wait_for_event(&codex, |event| match event {
|
||||
EventMsg::ItemStarted(started) => {
|
||||
if let TurnItem::FileChange(item) = &started.item {
|
||||
saw_file_change_started = true;
|
||||
assert_eq!(item.id, call_id);
|
||||
assert_eq!(item.status, None);
|
||||
}
|
||||
false
|
||||
}
|
||||
EventMsg::ItemCompleted(completed) => {
|
||||
if let TurnItem::FileChange(item) = &completed.item {
|
||||
saw_file_change_completed = true;
|
||||
assert_eq!(item.id, call_id);
|
||||
assert_eq!(
|
||||
item.status,
|
||||
Some(codex_protocol::protocol::PatchApplyStatus::Completed)
|
||||
);
|
||||
}
|
||||
false
|
||||
}
|
||||
EventMsg::PatchApplyBegin(begin) => {
|
||||
saw_patch_begin = true;
|
||||
assert_eq!(begin.call_id, call_id);
|
||||
@@ -383,6 +405,14 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<()
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
saw_file_change_started,
|
||||
"expected ItemStarted for TurnItem::FileChange"
|
||||
);
|
||||
assert!(
|
||||
saw_file_change_completed,
|
||||
"expected ItemCompleted for TurnItem::FileChange"
|
||||
);
|
||||
assert!(saw_patch_begin, "expected PatchApplyBegin event");
|
||||
let patch_end_success =
|
||||
patch_end_success.expect("expected PatchApplyEnd event to capture success flag");
|
||||
|
||||
@@ -44,7 +44,6 @@ async fn emits_warning_when_unstable_features_enabled_via_config() {
|
||||
} = thread_manager
|
||||
.resume_thread_with_history(
|
||||
config.clone(),
|
||||
codex_core::thread_store_from_config(&config),
|
||||
InitialHistory::New,
|
||||
auth_manager,
|
||||
/*persist_extended_history*/ false,
|
||||
@@ -92,7 +91,6 @@ async fn suppresses_warning_when_configured() {
|
||||
} = thread_manager
|
||||
.resume_thread_with_history(
|
||||
config.clone(),
|
||||
codex_core::thread_store_from_config(&config),
|
||||
InitialHistory::New,
|
||||
auth_manager,
|
||||
/*persist_extended_history*/ false,
|
||||
|
||||
@@ -71,7 +71,6 @@ async fn window_id_advances_after_compact_persists_on_resume_and_resets_on_fork(
|
||||
.fork_thread(
|
||||
/*snapshot*/ 0usize,
|
||||
resumed.config.clone(),
|
||||
codex_core::thread_store_from_config(&resumed.config),
|
||||
rollout_path,
|
||||
/*persist_extended_history*/ false,
|
||||
/*parent_trace*/ None,
|
||||
|
||||
@@ -78,6 +78,8 @@ ignore = [
|
||||
# TODO(fcoury): remove this exception when syntect drops yaml-rust and bincode, or updates to versions that have fixed the vulnerabilities.
|
||||
{ id = "RUSTSEC-2024-0320", reason = "yaml-rust is unmaintained; pulled in via syntect v5.3.0 used by codex-tui for syntax highlighting; no fixed release yet" },
|
||||
{ id = "RUSTSEC-2025-0141", reason = "bincode is unmaintained; pulled in via syntect v5.3.0 used by codex-tui for syntax highlighting; no fixed release yet" },
|
||||
{ id = "RUSTSEC-2026-0118", reason = "hickory-proto v0.25.2 is pulled in via rama-dns/rama-tcp used by codex-network-proxy; DNSSEC features are not enabled; remove when rama updates to hickory 0.26.1 or hickory-net" },
|
||||
{ id = "RUSTSEC-2026-0119", reason = "hickory-proto v0.25.2 is pulled in via rama-dns/rama-tcp used by codex-network-proxy; no fixed rama release is available yet; remove when rama updates to hickory 0.26.1 or hickory-net" },
|
||||
]
|
||||
# If this is true, then cargo deny will use the git executable to fetch advisory database.
|
||||
# If this is false, then it uses a built-in git library.
|
||||
|
||||
@@ -30,6 +30,10 @@ impl FeatureConfig for MultiAgentV2ConfigToml {
|
||||
fn enabled(&self) -> Option<bool> {
|
||||
self.enabled
|
||||
}
|
||||
|
||||
fn set_enabled(&mut self, enabled: bool) {
|
||||
self.enabled = Some(enabled);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
|
||||
@@ -45,4 +49,8 @@ impl FeatureConfig for AppsMcpPathOverrideConfigToml {
|
||||
fn enabled(&self) -> Option<bool> {
|
||||
self.enabled.or(self.path.as_ref().map(|_| true))
|
||||
}
|
||||
|
||||
fn set_enabled(&mut self, enabled: bool) {
|
||||
self.enabled = Some(enabled);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -593,6 +593,37 @@ impl FeaturesToml {
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
pub fn materialize_resolved_enabled(&mut self, features: &Features) {
|
||||
let Self {
|
||||
multi_agent_v2,
|
||||
apps_mcp_path_override,
|
||||
entries,
|
||||
} = self;
|
||||
for key in legacy::legacy_feature_keys() {
|
||||
entries.remove(key);
|
||||
}
|
||||
for spec in FEATURES {
|
||||
let enabled = features.enabled(spec.id);
|
||||
if spec.id == Feature::MultiAgentV2 {
|
||||
materialize_resolved_feature_enabled(multi_agent_v2, enabled);
|
||||
} else if spec.id == Feature::AppsMcpPathOverride {
|
||||
materialize_resolved_feature_enabled(apps_mcp_path_override, enabled);
|
||||
} else {
|
||||
entries.insert(spec.key.to_string(), enabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn materialize_resolved_feature_enabled<T: FeatureConfig>(
|
||||
feature: &mut Option<FeatureToml<T>>,
|
||||
enabled: bool,
|
||||
) {
|
||||
match feature {
|
||||
Some(feature) => feature.set_enabled(enabled),
|
||||
None => *feature = Some(FeatureToml::Enabled(enabled)),
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BTreeMap<String, bool>> for FeaturesToml {
|
||||
@@ -620,12 +651,20 @@ impl<T: FeatureConfig> FeatureToml<T> {
|
||||
Self::Config(config) => config.enabled(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_enabled(&mut self, enabled: bool) {
|
||||
match self {
|
||||
Self::Enabled(value) => *value = enabled,
|
||||
Self::Config(config) => config.set_enabled(enabled),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A trait to be implemented by custom feature config structs when defining a feature that needs more configuration than
|
||||
// just enabled/disabled.
|
||||
pub trait FeatureConfig {
|
||||
fn enabled(&self) -> Option<bool>;
|
||||
fn set_enabled(&mut self, enabled: bool);
|
||||
}
|
||||
|
||||
/// Single, easy-to-read registry of all feature definitions.
|
||||
|
||||
@@ -490,6 +490,54 @@ usage_hint_enabled = false
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn materialize_resolved_enabled_writes_all_features_and_preserves_custom_config() {
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::CodeMode);
|
||||
features.enable(Feature::MultiAgentV2);
|
||||
features.disable(Feature::ToolSearch);
|
||||
|
||||
let mut features_toml = FeaturesToml {
|
||||
multi_agent_v2: Some(FeatureToml::Config(crate::MultiAgentV2ConfigToml {
|
||||
enabled: Some(false),
|
||||
min_wait_timeout_ms: Some(2500),
|
||||
..Default::default()
|
||||
})),
|
||||
entries: BTreeMap::from([("include_apply_patch_tool".to_string(), true)]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
features_toml.materialize_resolved_enabled(&features);
|
||||
|
||||
let entries = features_toml.entries();
|
||||
assert_eq!(entries.get("include_apply_patch_tool"), None);
|
||||
for spec in crate::FEATURES {
|
||||
assert_eq!(
|
||||
entries.get(spec.key),
|
||||
Some(&features.enabled(spec.id)),
|
||||
"{}",
|
||||
spec.key
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
features_toml.multi_agent_v2,
|
||||
Some(FeatureToml::Config(crate::MultiAgentV2ConfigToml {
|
||||
enabled: Some(true),
|
||||
min_wait_timeout_ms: Some(2500),
|
||||
..Default::default()
|
||||
}))
|
||||
);
|
||||
let replayed = Features::from_sources(
|
||||
FeatureConfigSource {
|
||||
features: Some(&features_toml),
|
||||
..Default::default()
|
||||
},
|
||||
FeatureConfigSource::default(),
|
||||
FeatureOverrides::default(),
|
||||
);
|
||||
assert_eq!(replayed.enabled(Feature::ApplyPatchFreeform), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unstable_warning_event_only_mentions_enabled_under_development_features() {
|
||||
let mut configured_features = Table::new();
|
||||
|
||||
@@ -13,7 +13,6 @@ use codex_core::CodexThread;
|
||||
use codex_core::NewThread;
|
||||
use codex_core::ThreadManager;
|
||||
use codex_core::config::Config as CodexConfig;
|
||||
use codex_core::thread_store_from_config;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::AgentMessageEvent;
|
||||
use codex_protocol::protocol::ApplyPatchApprovalRequestEvent;
|
||||
@@ -69,10 +68,7 @@ pub async fn run_codex_tool_session(
|
||||
thread_id,
|
||||
thread,
|
||||
session_configured,
|
||||
} = match thread_manager
|
||||
.start_thread(config.clone(), thread_store_from_config(&config))
|
||||
.await
|
||||
{
|
||||
} = match thread_manager.start_thread(config.clone()).await {
|
||||
Ok(res) => res,
|
||||
Err(e) => {
|
||||
let result = CallToolResult {
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::sync::Arc;
|
||||
use codex_arg0::Arg0DispatchPaths;
|
||||
use codex_core::ThreadManager;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::thread_store_from_config;
|
||||
use codex_exec_server::EnvironmentManager;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::default_client::USER_AGENT_SUFFIX;
|
||||
@@ -65,6 +66,7 @@ impl MessageProcessor {
|
||||
SessionSource::Mcp,
|
||||
environment_manager,
|
||||
/*analytics_events_client*/ None,
|
||||
thread_store_from_config(config.as_ref()),
|
||||
));
|
||||
Self {
|
||||
outgoing,
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
You have access to a memory folder with guidance from prior runs. It can save
|
||||
time and help you stay consistent. Use it whenever it is likely to help.
|
||||
|
||||
Never update memories. You can only read them.
|
||||
|
||||
Decision boundary: should you use memory for a new user query?
|
||||
|
||||
- Skip memory ONLY when the request is clearly self-contained and does not need
|
||||
@@ -121,6 +119,14 @@ rollout_summaries/2026-02-17T21-23-02-LN3m-weekly_memory_report_pivot_from_git_h
|
||||
- Never include memory citations inside pull-request messages.
|
||||
- Never cite blank lines; double-check ranges.
|
||||
|
||||
Updating memories:
|
||||
|
||||
You can update the memories **only** when explicitly asked by the user. This must always come from a direct request from the user.
|
||||
- Write your update in {{ base_path }}/extensions/ad_hoc/notes/
|
||||
- Each update must be one small file containing what you want to add/delete/update from the memories.
|
||||
- The name of this file must be `<timestamp>-<short slug>.md`
|
||||
- Do not try to edit the memory files yourself, only add one update note in {{ base_path }}/extensions/ad_hoc/notes/
|
||||
|
||||
========= MEMORY_SUMMARY BEGINS =========
|
||||
{{ memory_summary }}
|
||||
========= MEMORY_SUMMARY ENDS =========
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
use crate::memory_extensions_root;
|
||||
use std::path::Path;
|
||||
|
||||
pub(super) const INSTRUCTIONS: &str =
|
||||
include_str!("../../templates/extensions/ad_hoc/instructions.md");
|
||||
|
||||
pub(super) async fn seed_instructions(memory_root: &Path) -> std::io::Result<()> {
|
||||
let extension_root = memory_extensions_root(memory_root).join("ad_hoc");
|
||||
let instructions_path = extension_root.join("instructions.md");
|
||||
|
||||
tokio::fs::create_dir_all(&extension_root).await?;
|
||||
match tokio::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&instructions_path)
|
||||
.await
|
||||
{
|
||||
Ok(mut file) => {
|
||||
tokio::io::AsyncWriteExt::write_all(&mut file, INSTRUCTIONS.as_bytes()).await
|
||||
}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "ad_hoc_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,36 @@
|
||||
use super::*;
|
||||
use crate::memory_extensions_root;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn seeds_instructions_without_overwriting_existing_file() {
|
||||
let codex_home = TempDir::new().expect("create temp codex home");
|
||||
let memory_root = codex_home.path().join("memories");
|
||||
let instructions_path = memory_extensions_root(&memory_root).join("ad_hoc/instructions.md");
|
||||
|
||||
seed_instructions(&memory_root)
|
||||
.await
|
||||
.expect("seed ad-hoc instructions");
|
||||
|
||||
assert_eq!(
|
||||
tokio::fs::read_to_string(&instructions_path)
|
||||
.await
|
||||
.expect("read seeded ad-hoc instructions"),
|
||||
INSTRUCTIONS
|
||||
);
|
||||
|
||||
tokio::fs::write(&instructions_path, "custom instructions")
|
||||
.await
|
||||
.expect("write custom instructions");
|
||||
seed_instructions(&memory_root)
|
||||
.await
|
||||
.expect("seed ad-hoc instructions again");
|
||||
|
||||
assert_eq!(
|
||||
tokio::fs::read_to_string(&instructions_path)
|
||||
.await
|
||||
.expect("read custom ad-hoc instructions"),
|
||||
"custom instructions"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
mod ad_hoc;
|
||||
mod prune;
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
pub(crate) async fn seed_extension_instructions(memory_root: &Path) -> std::io::Result<()> {
|
||||
ad_hoc::seed_instructions(memory_root).await
|
||||
}
|
||||
|
||||
pub use prune::prune_old_extension_resources;
|
||||
+1
-1
@@ -96,5 +96,5 @@ fn resource_timestamp(file_name: &str) -> Option<DateTime<Utc>> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "extensions_tests.rs"]
|
||||
#[path = "prune_tests.rs"]
|
||||
mod tests;
|
||||
+1
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use crate::memory_extensions_root;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
|
||||
@@ -8,7 +8,6 @@ use codex_core::ThreadManager;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::content_items_to_text;
|
||||
use codex_core::resolve_installation_id;
|
||||
use codex_core::thread_store_from_config;
|
||||
use codex_features::Feature;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::CodexAuth;
|
||||
@@ -237,7 +236,6 @@ impl MemoryStartupContext {
|
||||
} = self
|
||||
.thread_manager
|
||||
.start_thread_with_options(StartThreadOptions {
|
||||
thread_store: thread_store_from_config(&config),
|
||||
config,
|
||||
initial_history: InitialHistory::New,
|
||||
session_source: Some(SessionSource::Internal(
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use crate::extensions::seed_extension_instructions;
|
||||
use crate::guard;
|
||||
use crate::memory_root;
|
||||
use crate::metrics::MEMORY_STARTUP;
|
||||
use crate::phase1;
|
||||
use crate::phase2;
|
||||
@@ -47,6 +49,11 @@ pub fn start_memories_startup_task(
|
||||
}
|
||||
|
||||
tokio::spawn(async move {
|
||||
let root = memory_root(&config.codex_home);
|
||||
if let Err(err) = seed_extension_instructions(&root).await {
|
||||
warn!("failed seeding memory extension instructions: {err}");
|
||||
}
|
||||
|
||||
// Clean memories to make preserve DB size. This does not consume tokens so can be
|
||||
// done before the quota check.
|
||||
phase1::prune(context.as_ref(), &config).await;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user