mirror of
https://github.com/openai/codex.git
synced 2026-04-23 22:24:57 +00:00
Compare commits
77 Commits
blocking-s
...
jif/fork-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
644cb5fd6c | ||
|
|
bec31716ff | ||
|
|
c51d88da6b | ||
|
|
45f3250eec | ||
|
|
51307eaf07 | ||
|
|
42ae738f67 | ||
|
|
00ef9d3784 | ||
|
|
f3989f6092 | ||
|
|
dbec741ef0 | ||
|
|
06e7667d0e | ||
|
|
1ef1fe67ec | ||
|
|
ee191dbe81 | ||
|
|
ad9eeeb287 | ||
|
|
6b5b9a687e | ||
|
|
58e1e570fa | ||
|
|
ec93b6daf3 | ||
|
|
4d4778ec1c | ||
|
|
77c457121e | ||
|
|
5ebdc9af1b | ||
|
|
f6a7da4ac3 | ||
|
|
1d09ac89a1 | ||
|
|
127e307f89 | ||
|
|
21ad1c1c90 | ||
|
|
349734e38d | ||
|
|
2222cab9ea | ||
|
|
c2f8c4e9f4 | ||
|
|
72b95db12f | ||
|
|
37ee6bf2c3 | ||
|
|
8b1e397211 | ||
|
|
85e687c74a | ||
|
|
9ee855ec57 | ||
|
|
4b78e2ab09 | ||
|
|
85e2fabc9f | ||
|
|
a8d5ad37b8 | ||
|
|
32e4a3a4d7 | ||
|
|
f443555728 | ||
|
|
ff4ca9959c | ||
|
|
5b25915d7e | ||
|
|
c0564edebe | ||
|
|
c936c68c84 | ||
|
|
41760f8a09 | ||
|
|
440c7acd8f | ||
|
|
0cc3b50228 | ||
|
|
8532876ad8 | ||
|
|
44d92675eb | ||
|
|
a421eba31f | ||
|
|
40006808a3 | ||
|
|
ba58184349 | ||
|
|
14df5c9492 | ||
|
|
cb85a7b96e | ||
|
|
3f12f1140f | ||
|
|
c22cd2e953 | ||
|
|
ebd485b1a0 | ||
|
|
457c9fdb87 | ||
|
|
6eeaf46ac1 | ||
|
|
aaec8abf58 | ||
|
|
cbd7d0d543 | ||
|
|
fabdbfef9c | ||
|
|
8b314e2d04 | ||
|
|
963009737f | ||
|
|
e953092949 | ||
|
|
28ff364c3a | ||
|
|
63a3f3941a | ||
|
|
7ceabac707 | ||
|
|
2a24ae36c2 | ||
|
|
5071cc8fff | ||
|
|
9c765f1217 | ||
|
|
7116d2a6a4 | ||
|
|
94f1a61df5 | ||
|
|
7ec1311aff | ||
|
|
b9f260057c | ||
|
|
72af9e3092 | ||
|
|
8f0d83eb11 | ||
|
|
26d667e152 | ||
|
|
d1cf2b967c | ||
|
|
78afe914e1 | ||
|
|
1a8c1a4d9a |
5
.github/workflows/cla.yml
vendored
5
.github/workflows/cla.yml
vendored
@@ -46,7 +46,4 @@ jobs:
|
||||
path-to-document: https://github.com/openai/codex/blob/main/docs/CLA.md
|
||||
path-to-signatures: signatures/cla.json
|
||||
branch: cla-signatures
|
||||
allowlist: |
|
||||
codex
|
||||
dependabot
|
||||
dependabot[bot]
|
||||
allowlist: codex,dependabot,dependabot[bot],github-actions[bot]
|
||||
|
||||
113
codex-rs/Cargo.lock
generated
113
codex-rs/Cargo.lock
generated
@@ -198,9 +198,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arboard"
|
||||
version = "3.6.0"
|
||||
version = "3.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55f533f8e0af236ffe5eb979b99381df3258853f00ba2e44b6e1955292c75227"
|
||||
checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
|
||||
dependencies = [
|
||||
"clipboard-win",
|
||||
"image",
|
||||
@@ -212,7 +212,7 @@ dependencies = [
|
||||
"objc2-foundation",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.60.2",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
@@ -1068,6 +1068,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1144,6 +1145,7 @@ dependencies = [
|
||||
"codex-apply-patch",
|
||||
"codex-arg0",
|
||||
"codex-async-utils",
|
||||
"codex-core",
|
||||
"codex-execpolicy",
|
||||
"codex-file-search",
|
||||
"codex-git",
|
||||
@@ -1185,6 +1187,7 @@ dependencies = [
|
||||
"seccompiler",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"serial_test",
|
||||
"sha1",
|
||||
"sha2",
|
||||
@@ -1280,6 +1283,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"shlex",
|
||||
"starlark",
|
||||
"tempfile",
|
||||
"thiserror 2.0.17",
|
||||
]
|
||||
|
||||
@@ -1611,6 +1615,7 @@ dependencies = [
|
||||
"textwrap 0.16.2",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tokio-util",
|
||||
"toml",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
@@ -2535,7 +2540,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"rustix 1.0.8",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3292,9 +3297,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.8"
|
||||
version = "0.25.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7"
|
||||
checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
@@ -3302,8 +3307,8 @@ dependencies = [
|
||||
"num-traits",
|
||||
"png",
|
||||
"tiff",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
"zune-core 0.5.0",
|
||||
"zune-jpeg 0.5.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3439,7 +3444,7 @@ checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4463,6 +4468,12 @@ version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "pastey"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57d6c094ee800037dff99e02cab0eaf3142826586742a270ab3d7a62656bd27a"
|
||||
|
||||
[[package]]
|
||||
name = "path-absolutize"
|
||||
version = "3.1.1"
|
||||
@@ -5078,9 +5089,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.23"
|
||||
version = "0.12.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb"
|
||||
checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
@@ -5141,8 +5152,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rmcp"
|
||||
version = "0.9.0"
|
||||
source = "git+https://github.com/bolinfest/rust-sdk?branch=pr556#4d9cc16f4c76c84486344f542ed9a3e9364019ba"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38b18323edc657390a6ed4d7a9110b0dec2dc3ed128eb2a123edfbafabdbddc5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64",
|
||||
@@ -5153,7 +5165,7 @@ dependencies = [
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"oauth2",
|
||||
"paste",
|
||||
"pastey",
|
||||
"pin-project-lite",
|
||||
"process-wrap",
|
||||
"rand 0.9.2",
|
||||
@@ -5175,8 +5187,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rmcp-macros"
|
||||
version = "0.9.0"
|
||||
source = "git+https://github.com/bolinfest/rust-sdk?branch=pr556#4d9cc16f4c76c84486344f542ed9a3e9364019ba"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c75d0a62676bf8c8003c4e3c348e2ceb6a7b3e48323681aaf177fdccdac2ce50"
|
||||
dependencies = [
|
||||
"darling 0.21.3",
|
||||
"proc-macro2",
|
||||
@@ -5216,7 +5229,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.4.15",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5735,9 +5748,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "3.14.0"
|
||||
version = "3.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2c45cd61fefa9db6f254525d46e392b852e0e61d9a1fd36e5bd183450a556d5"
|
||||
checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"chrono",
|
||||
@@ -5746,8 +5759,7 @@ dependencies = [
|
||||
"indexmap 2.12.0",
|
||||
"schemars 0.9.0",
|
||||
"schemars 1.0.4",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_core",
|
||||
"serde_json",
|
||||
"serde_with_macros",
|
||||
"time",
|
||||
@@ -5755,16 +5767,29 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_with_macros"
|
||||
version = "3.14.0"
|
||||
version = "3.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "de90945e6565ce0d9a25098082ed4ee4002e047cb59892c318d66821e14bb30f"
|
||||
checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c"
|
||||
dependencies = [
|
||||
"darling 0.20.11",
|
||||
"darling 0.21.3",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.104",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_yaml"
|
||||
version = "0.9.34+deprecated"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||
dependencies = [
|
||||
"indexmap 2.12.0",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
"unsafe-libyaml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serial2"
|
||||
version = "0.2.31"
|
||||
@@ -6408,7 +6433,7 @@ dependencies = [
|
||||
"half",
|
||||
"quick-error",
|
||||
"weezl",
|
||||
"zune-jpeg",
|
||||
"zune-jpeg 0.4.19",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6576,6 +6601,7 @@ dependencies = [
|
||||
"futures-sink",
|
||||
"futures-util",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
@@ -6713,9 +6739,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.41"
|
||||
version = "0.1.43"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
|
||||
checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
@@ -6737,9 +6763,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.30"
|
||||
version = "0.1.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903"
|
||||
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -6748,9 +6774,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.34"
|
||||
version = "0.1.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678"
|
||||
checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"valuable",
|
||||
@@ -6979,6 +7005,12 @@ version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "unsafe-libyaml"
|
||||
version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
@@ -7368,7 +7400,7 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
|
||||
dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8093,13 +8125,28 @@ version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "111f7d9820f05fd715df3144e254d6fc02ee4088b0644c0ffd0efc9e6d9d2773"
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpeg"
|
||||
version = "0.4.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c9e525af0a6a658e031e95f14b7f889976b74a11ba0eca5a5fc9ac8a1c43a6a"
|
||||
dependencies = [
|
||||
"zune-core",
|
||||
"zune-core 0.4.12",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpeg"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc6fb7703e32e9a07fb3f757360338b3a567a5054f21b5f52a666752e333d58e"
|
||||
dependencies = [
|
||||
"zune-core 0.5.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -59,15 +59,15 @@ license = "Apache-2.0"
|
||||
# Internal
|
||||
app_test_support = { path = "app-server/tests/common" }
|
||||
codex-ansi-escape = { path = "ansi-escape" }
|
||||
codex-api = { path = "codex-api" }
|
||||
codex-app-server = { path = "app-server" }
|
||||
codex-app-server-protocol = { path = "app-server-protocol" }
|
||||
codex-apply-patch = { path = "apply-patch" }
|
||||
codex-arg0 = { path = "arg0" }
|
||||
codex-async-utils = { path = "async-utils" }
|
||||
codex-backend-client = { path = "backend-client" }
|
||||
codex-api = { path = "codex-api" }
|
||||
codex-client = { path = "codex-client" }
|
||||
codex-chatgpt = { path = "chatgpt" }
|
||||
codex-client = { path = "codex-client" }
|
||||
codex-common = { path = "common" }
|
||||
codex-core = { path = "core" }
|
||||
codex-exec = { path = "exec" }
|
||||
@@ -136,7 +136,7 @@ icu_decimal = "2.1"
|
||||
icu_locale_core = "2.1"
|
||||
icu_provider = { version = "2.1", features = ["sync"] }
|
||||
ignore = "0.4.23"
|
||||
image = { version = "^0.25.8", default-features = false }
|
||||
image = { version = "^0.25.9", default-features = false }
|
||||
indexmap = "2.12.0"
|
||||
insta = "1.43.2"
|
||||
itertools = "0.14.0"
|
||||
@@ -169,16 +169,17 @@ pulldown-cmark = "0.10"
|
||||
rand = "0.9"
|
||||
ratatui = "0.29.0"
|
||||
ratatui-macros = "0.6.0"
|
||||
regex-lite = "0.1.7"
|
||||
regex = "1.12.2"
|
||||
regex-lite = "0.1.7"
|
||||
reqwest = "0.12"
|
||||
rmcp = { version = "0.9.0", default-features = false }
|
||||
rmcp = { version = "0.10.0", default-features = false }
|
||||
schemars = "0.8.22"
|
||||
seccompiler = "0.5.0"
|
||||
sentry = "0.34.0"
|
||||
serde = "1"
|
||||
serde_json = "1"
|
||||
serde_with = "3.14"
|
||||
serde_yaml = "0.9"
|
||||
serde_with = "3.16"
|
||||
serial_test = "3.2.0"
|
||||
sha1 = "0.10.6"
|
||||
sha2 = "0.10"
|
||||
@@ -203,7 +204,7 @@ tokio-util = "0.7.16"
|
||||
toml = "0.9.5"
|
||||
toml_edit = "0.23.5"
|
||||
tonic = "0.13.1"
|
||||
tracing = "0.1.41"
|
||||
tracing = "0.1.43"
|
||||
tracing-appender = "0.2.3"
|
||||
tracing-subscriber = "0.3.20"
|
||||
tracing-test = "0.2.5"
|
||||
@@ -288,7 +289,6 @@ opt-level = 0
|
||||
# ratatui = { path = "../../ratatui" }
|
||||
crossterm = { git = "https://github.com/nornagon/crossterm", branch = "nornagon/color-query" }
|
||||
ratatui = { git = "https://github.com/nornagon/ratatui", branch = "nornagon-v0.29.0-patch" }
|
||||
rmcp = { git = "https://github.com/bolinfest/rust-sdk", branch = "pr556" }
|
||||
|
||||
# Uncomment to debug local changes.
|
||||
# rmcp = { path = "../../rust-sdk/crates/rmcp" }
|
||||
|
||||
@@ -131,7 +131,7 @@ client_request_definitions! {
|
||||
},
|
||||
ReviewStart => "review/start" {
|
||||
params: v2::ReviewStartParams,
|
||||
response: v2::TurnStartResponse,
|
||||
response: v2::ReviewStartResponse,
|
||||
},
|
||||
|
||||
ModelList => "model/list" {
|
||||
@@ -164,6 +164,12 @@ client_request_definitions! {
|
||||
response: v2::FeedbackUploadResponse,
|
||||
},
|
||||
|
||||
/// Execute a command (argv vector) under the server's sandbox.
|
||||
OneOffCommandExec => "command/exec" {
|
||||
params: v2::CommandExecParams,
|
||||
response: v2::CommandExecResponse,
|
||||
},
|
||||
|
||||
ConfigRead => "config/read" {
|
||||
params: v2::ConfigReadParams,
|
||||
response: v2::ConfigReadResponse,
|
||||
@@ -506,10 +512,12 @@ server_notification_definitions! {
|
||||
TurnStarted => "turn/started" (v2::TurnStartedNotification),
|
||||
TurnCompleted => "turn/completed" (v2::TurnCompletedNotification),
|
||||
TurnDiffUpdated => "turn/diff/updated" (v2::TurnDiffUpdatedNotification),
|
||||
TurnPlanUpdated => "turn/plan/updated" (v2::TurnPlanUpdatedNotification),
|
||||
ItemStarted => "item/started" (v2::ItemStartedNotification),
|
||||
ItemCompleted => "item/completed" (v2::ItemCompletedNotification),
|
||||
AgentMessageDelta => "item/agentMessage/delta" (v2::AgentMessageDeltaNotification),
|
||||
CommandExecutionOutputDelta => "item/commandExecution/outputDelta" (v2::CommandExecutionOutputDeltaNotification),
|
||||
FileChangeOutputDelta => "item/fileChange/outputDelta" (v2::FileChangeOutputDeltaNotification),
|
||||
McpToolCallProgress => "item/mcpToolCall/progress" (v2::McpToolCallProgressNotification),
|
||||
AccountUpdated => "account/updated" (v2::AccountUpdatedNotification),
|
||||
AccountRateLimitsUpdated => "account/rateLimits/updated" (v2::AccountRateLimitsUpdatedNotification),
|
||||
|
||||
15
codex-rs/app-server-protocol/src/protocol/mappers.rs
Normal file
15
codex-rs/app-server-protocol/src/protocol/mappers.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
use crate::protocol::v1;
|
||||
use crate::protocol::v2;
|
||||
|
||||
impl From<v1::ExecOneOffCommandParams> for v2::CommandExecParams {
|
||||
fn from(value: v1::ExecOneOffCommandParams) -> Self {
|
||||
Self {
|
||||
command: value.command,
|
||||
timeout_ms: value
|
||||
.timeout_ms
|
||||
.map(|timeout| i64::try_from(timeout).unwrap_or(60_000)),
|
||||
cwd: value.cwd,
|
||||
sandbox_policy: value.sandbox_policy.map(std::convert::Into::into),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
// Exposes protocol pieces used by `lib.rs` via `pub use protocol::common::*;`.
|
||||
|
||||
pub mod common;
|
||||
mod mappers;
|
||||
pub mod thread_history;
|
||||
pub mod v1;
|
||||
pub mod v2;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::protocol::v2::ThreadItem;
|
||||
use crate::protocol::v2::Turn;
|
||||
use crate::protocol::v2::TurnError;
|
||||
use crate::protocol::v2::TurnStatus;
|
||||
use crate::protocol::v2::UserInput;
|
||||
use codex_protocol::protocol::AgentReasoningEvent;
|
||||
@@ -142,6 +143,7 @@ impl ThreadHistoryBuilder {
|
||||
PendingTurn {
|
||||
id: self.next_turn_id(),
|
||||
items: Vec::new(),
|
||||
error: None,
|
||||
status: TurnStatus::Completed,
|
||||
}
|
||||
}
|
||||
@@ -190,6 +192,7 @@ impl ThreadHistoryBuilder {
|
||||
struct PendingTurn {
|
||||
id: String,
|
||||
items: Vec<ThreadItem>,
|
||||
error: Option<TurnError>,
|
||||
status: TurnStatus,
|
||||
}
|
||||
|
||||
@@ -198,6 +201,7 @@ impl From<PendingTurn> for Turn {
|
||||
Self {
|
||||
id: value.id,
|
||||
items: value.items,
|
||||
error: value.error,
|
||||
status: value.status,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent;
|
||||
use codex_protocol::items::TurnItem as CoreTurnItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::parse_command::ParsedCommand as CoreParsedCommand;
|
||||
use codex_protocol::plan_tool::PlanItemArg as CorePlanItemArg;
|
||||
use codex_protocol::plan_tool::StepStatus as CorePlanStepStatus;
|
||||
use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo;
|
||||
use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot;
|
||||
use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot;
|
||||
@@ -130,6 +132,12 @@ v2_enum_from_core!(
|
||||
}
|
||||
);
|
||||
|
||||
v2_enum_from_core!(
|
||||
pub enum ReviewDelivery from codex_protocol::protocol::ReviewDelivery {
|
||||
Inline, Detached
|
||||
}
|
||||
);
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
@@ -624,6 +632,26 @@ pub struct FeedbackUploadResponse {
|
||||
pub thread_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct CommandExecParams {
|
||||
pub command: Vec<String>,
|
||||
#[ts(type = "number | null")]
|
||||
pub timeout_ms: Option<i64>,
|
||||
pub cwd: Option<PathBuf>,
|
||||
pub sandbox_policy: Option<SandboxPolicy>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct CommandExecResponse {
|
||||
pub exit_code: i32,
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
}
|
||||
|
||||
// === Threads, Turns, and Items ===
|
||||
// Thread APIs
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)]
|
||||
@@ -758,6 +786,7 @@ pub struct Thread {
|
||||
/// Model provider used for this thread (for example, 'openai').
|
||||
pub model_provider: String,
|
||||
/// Unix timestamp (in seconds) when the thread was created.
|
||||
#[ts(type = "number")]
|
||||
pub created_at: i64,
|
||||
/// [UNSTABLE] Path to the thread on disk.
|
||||
pub path: PathBuf,
|
||||
@@ -848,8 +877,9 @@ pub struct Turn {
|
||||
/// For all other responses and notifications returning a Turn,
|
||||
/// the items field will be an empty list.
|
||||
pub items: Vec<ThreadItem>,
|
||||
#[serde(flatten)]
|
||||
pub status: TurnStatus,
|
||||
/// Only populated when the Turn's status is failed.
|
||||
pub error: Option<TurnError>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, Error)]
|
||||
@@ -871,12 +901,12 @@ pub struct ErrorNotification {
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(tag = "status", rename_all = "camelCase")]
|
||||
#[ts(tag = "status", export_to = "v2/")]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub enum TurnStatus {
|
||||
Completed,
|
||||
Interrupted,
|
||||
Failed { error: TurnError },
|
||||
Failed,
|
||||
InProgress,
|
||||
}
|
||||
|
||||
@@ -908,9 +938,22 @@ pub struct ReviewStartParams {
|
||||
pub thread_id: String,
|
||||
pub target: ReviewTarget,
|
||||
|
||||
/// When true, also append the final review message to the original thread.
|
||||
/// Where to run the review: inline (default) on the current thread or
|
||||
/// detached on a new thread (returned in `reviewThreadId`).
|
||||
#[serde(default)]
|
||||
pub append_to_original_thread: bool,
|
||||
pub delivery: Option<ReviewDelivery>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ReviewStartResponse {
|
||||
pub turn: Turn,
|
||||
/// Identifies the thread where the review runs.
|
||||
///
|
||||
/// For inline reviews, this is the original thread id.
|
||||
/// For detached reviews, this is the id of the new review thread.
|
||||
pub review_thread_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
@@ -1020,6 +1063,8 @@ pub enum ThreadItem {
|
||||
command: String,
|
||||
/// The command's working directory.
|
||||
cwd: PathBuf,
|
||||
/// Identifier for the underlying PTY process (when available).
|
||||
process_id: Option<String>,
|
||||
status: CommandExecutionStatus,
|
||||
/// A best-effort parsing of the command to understand the action(s) it will perform.
|
||||
/// This returns a list of CommandAction objects because a single shell command may
|
||||
@@ -1030,6 +1075,7 @@ pub enum ThreadItem {
|
||||
/// The command's exit code.
|
||||
exit_code: Option<i32>,
|
||||
/// The duration of the command execution in milliseconds.
|
||||
#[ts(type = "number | null")]
|
||||
duration_ms: Option<i64>,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -1061,7 +1107,10 @@ pub enum ThreadItem {
|
||||
ImageView { id: String, path: String },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
CodeReview { id: String, review: String },
|
||||
EnteredReviewMode { id: String, review: String },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
ExitedReviewMode { id: String, review: String },
|
||||
}
|
||||
|
||||
impl From<CoreTurnItem> for ThreadItem {
|
||||
@@ -1206,10 +1255,56 @@ pub struct TurnCompletedNotification {
|
||||
/// Notification that the turn-level unified diff has changed.
|
||||
/// Contains the latest aggregated diff across all file changes in the turn.
|
||||
pub struct TurnDiffUpdatedNotification {
|
||||
pub thread_id: String,
|
||||
pub turn_id: String,
|
||||
pub diff: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct TurnPlanUpdatedNotification {
|
||||
pub turn_id: String,
|
||||
pub explanation: Option<String>,
|
||||
pub plan: Vec<TurnPlanStep>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct TurnPlanStep {
|
||||
pub step: String,
|
||||
pub status: TurnPlanStepStatus,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub enum TurnPlanStepStatus {
|
||||
Pending,
|
||||
InProgress,
|
||||
Completed,
|
||||
}
|
||||
|
||||
impl From<CorePlanItemArg> for TurnPlanStep {
|
||||
fn from(value: CorePlanItemArg) -> Self {
|
||||
Self {
|
||||
step: value.step,
|
||||
status: value.status.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CorePlanStepStatus> for TurnPlanStepStatus {
|
||||
fn from(value: CorePlanStepStatus) -> Self {
|
||||
match value {
|
||||
CorePlanStepStatus::Pending => Self::Pending,
|
||||
CorePlanStepStatus::InProgress => Self::InProgress,
|
||||
CorePlanStepStatus::Completed => Self::Completed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
@@ -1233,6 +1328,8 @@ pub struct ItemCompletedNotification {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct AgentMessageDeltaNotification {
|
||||
pub thread_id: String,
|
||||
pub turn_id: String,
|
||||
pub item_id: String,
|
||||
pub delta: String,
|
||||
}
|
||||
@@ -1241,8 +1338,11 @@ pub struct AgentMessageDeltaNotification {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ReasoningSummaryTextDeltaNotification {
|
||||
pub thread_id: String,
|
||||
pub turn_id: String,
|
||||
pub item_id: String,
|
||||
pub delta: String,
|
||||
#[ts(type = "number")]
|
||||
pub summary_index: i64,
|
||||
}
|
||||
|
||||
@@ -1250,7 +1350,10 @@ pub struct ReasoningSummaryTextDeltaNotification {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ReasoningSummaryPartAddedNotification {
|
||||
pub thread_id: String,
|
||||
pub turn_id: String,
|
||||
pub item_id: String,
|
||||
#[ts(type = "number")]
|
||||
pub summary_index: i64,
|
||||
}
|
||||
|
||||
@@ -1258,8 +1361,11 @@ pub struct ReasoningSummaryPartAddedNotification {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ReasoningTextDeltaNotification {
|
||||
pub thread_id: String,
|
||||
pub turn_id: String,
|
||||
pub item_id: String,
|
||||
pub delta: String,
|
||||
#[ts(type = "number")]
|
||||
pub content_index: i64,
|
||||
}
|
||||
|
||||
@@ -1267,6 +1373,18 @@ pub struct ReasoningTextDeltaNotification {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct CommandExecutionOutputDeltaNotification {
|
||||
pub thread_id: String,
|
||||
pub turn_id: String,
|
||||
pub item_id: String,
|
||||
pub delta: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct FileChangeOutputDeltaNotification {
|
||||
pub thread_id: String,
|
||||
pub turn_id: String,
|
||||
pub item_id: String,
|
||||
pub delta: String,
|
||||
}
|
||||
@@ -1275,6 +1393,8 @@ pub struct CommandExecutionOutputDeltaNotification {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct McpToolCallProgressNotification {
|
||||
pub thread_id: String,
|
||||
pub turn_id: String,
|
||||
pub item_id: String,
|
||||
pub message: String,
|
||||
}
|
||||
@@ -1380,7 +1500,9 @@ impl From<CoreRateLimitSnapshot> for RateLimitSnapshot {
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct RateLimitWindow {
|
||||
pub used_percent: i32,
|
||||
#[ts(type = "number | null")]
|
||||
pub window_duration_mins: Option<i64>,
|
||||
#[ts(type = "number | null")]
|
||||
pub resets_at: Option<i64>,
|
||||
}
|
||||
|
||||
|
||||
@@ -563,7 +563,9 @@ impl CodexClient {
|
||||
ServerNotification::TurnCompleted(payload) => {
|
||||
if payload.turn.id == turn_id {
|
||||
println!("\n< turn/completed notification: {:?}", payload.turn.status);
|
||||
if let TurnStatus::Failed { error } = &payload.turn.status {
|
||||
if payload.turn.status == TurnStatus::Failed
|
||||
&& let Some(error) = payload.turn.error
|
||||
{
|
||||
println!("[turn error] {}", error.message);
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# codex-app-server
|
||||
|
||||
`codex app-server` is the interface Codex uses to power rich interfaces such as the [Codex VS Code extension](https://marketplace.visualstudio.com/items?itemName=openai.chatgpt). The message schema is currently unstable, but those who wish to build experimental UIs on top of Codex may find it valuable.
|
||||
`codex app-server` is the interface Codex uses to power rich interfaces such as the [Codex VS Code extension](https://marketplace.visualstudio.com/items?itemName=openai.chatgpt).
|
||||
|
||||
## Table of Contents
|
||||
- [Protocol](#protocol)
|
||||
@@ -65,7 +65,8 @@ The JSON-RPC API exposes dedicated methods for managing Codex conversations. Thr
|
||||
- `thread/archive` — move a thread’s rollout file into the archived directory; returns `{}` on success.
|
||||
- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications.
|
||||
- `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`.
|
||||
- `review/start` — kick off Codex’s automated reviewer for a thread; responds like `turn/start` and emits a `item/completed` notification with a `codeReview` item when results are ready.
|
||||
- `review/start` — kick off Codex’s automated reviewer for a thread; responds like `turn/start` and emits `item/started`/`item/completed` notifications with `enteredReviewMode` and `exitedReviewMode` items, plus a final assistant `agentMessage` containing the review.
|
||||
- `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation).
|
||||
|
||||
### 1) Start or resume a thread
|
||||
|
||||
@@ -190,49 +191,75 @@ Use `review/start` to run Codex’s reviewer on the currently checked-out projec
|
||||
- `{"type":"baseBranch","branch":"main"}` — diff against the provided branch’s upstream (see prompt for the exact `git merge-base`/`git diff` instructions Codex will run).
|
||||
- `{"type":"commit","sha":"abc1234","title":"Optional subject"}` — review a specific commit.
|
||||
- `{"type":"custom","instructions":"Free-form reviewer instructions"}` — fallback prompt equivalent to the legacy manual review request.
|
||||
- `appendToOriginalThread` (bool, default `false`) — when `true`, Codex also records a final assistant-style message with the review summary in the original thread. When `false`, only the `codeReview` item is emitted for the review run and no extra message is added to the original thread.
|
||||
- `delivery` (`"inline"` or `"detached"`, default `"inline"`) — where the review runs:
|
||||
- `"inline"`: run the review as a new turn on the existing thread. The response’s `reviewThreadId` equals the original `threadId`, and no new `thread/started` notification is emitted.
|
||||
- `"detached"`: fork a new review thread from the parent conversation and run the review there. The response’s `reviewThreadId` is the id of this new review thread, and the server emits a `thread/started` notification for it before streaming review items.
|
||||
|
||||
Example request/response:
|
||||
|
||||
```json
|
||||
{ "method": "review/start", "id": 40, "params": {
|
||||
"threadId": "thr_123",
|
||||
"appendToOriginalThread": true,
|
||||
"delivery": "inline",
|
||||
"target": { "type": "commit", "sha": "1234567deadbeef", "title": "Polish tui colors" }
|
||||
} }
|
||||
{ "id": 40, "result": { "turn": {
|
||||
"id": "turn_900",
|
||||
"status": "inProgress",
|
||||
"items": [
|
||||
{ "type": "userMessage", "id": "turn_900", "content": [ { "type": "text", "text": "Review commit 1234567: Polish tui colors" } ] }
|
||||
],
|
||||
"error": null
|
||||
} } }
|
||||
{ "id": 40, "result": {
|
||||
"turn": {
|
||||
"id": "turn_900",
|
||||
"status": "inProgress",
|
||||
"items": [
|
||||
{ "type": "userMessage", "id": "turn_900", "content": [ { "type": "text", "text": "Review commit 1234567: Polish tui colors" } ] }
|
||||
],
|
||||
"error": null
|
||||
},
|
||||
"reviewThreadId": "thr_123"
|
||||
} }
|
||||
```
|
||||
|
||||
For a detached review, use `"delivery": "detached"`. The response is the same shape, but `reviewThreadId` will be the id of the new review thread (different from the original `threadId`). The server also emits a `thread/started` notification for that new thread before streaming the review turn.
|
||||
|
||||
Codex streams the usual `turn/started` notification followed by an `item/started`
|
||||
with the same `codeReview` item id so clients can show progress:
|
||||
with an `enteredReviewMode` item so clients can show progress:
|
||||
|
||||
```json
|
||||
{ "method": "item/started", "params": { "item": {
|
||||
"type": "codeReview",
|
||||
"type": "enteredReviewMode",
|
||||
"id": "turn_900",
|
||||
"review": "current changes"
|
||||
} } }
|
||||
```
|
||||
|
||||
When the reviewer finishes, the server emits `item/completed` containing the same
|
||||
`codeReview` item with the final review text:
|
||||
When the reviewer finishes, the server emits `item/started` and `item/completed`
|
||||
containing an `exitedReviewMode` item with the final review text:
|
||||
|
||||
```json
|
||||
{ "method": "item/completed", "params": { "item": {
|
||||
"type": "codeReview",
|
||||
"type": "exitedReviewMode",
|
||||
"id": "turn_900",
|
||||
"review": "Looks solid overall...\n\n- Prefer Stylize helpers — app.rs:10-20\n ..."
|
||||
} } }
|
||||
```
|
||||
|
||||
The `review` string is plain text that already bundles the overall explanation plus a bullet list for each structured finding (matching `ThreadItem::CodeReview` in the generated schema). Use this notification to render the reviewer output in your client.
|
||||
The `review` string is plain text that already bundles the overall explanation plus a bullet list for each structured finding (matching `ThreadItem::ExitedReviewMode` in the generated schema). Use this notification to render the reviewer output in your client.
|
||||
|
||||
### 7) One-off command execution
|
||||
|
||||
Run a standalone command (argv vector) in the server’s sandbox without creating a thread or turn:
|
||||
|
||||
```json
|
||||
{ "method": "command/exec", "id": 32, "params": {
|
||||
"command": ["ls", "-la"],
|
||||
"cwd": "/Users/me/project", // optional; defaults to server cwd
|
||||
"sandboxPolicy": { "type": "workspaceWrite" }, // optional; defaults to user config
|
||||
"timeoutMs": 10000 // optional; ms timeout; defaults to server timeout
|
||||
} }
|
||||
{ "id": 32, "result": { "exitCode": 0, "stdout": "...", "stderr": "" } }
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Empty `command` arrays are rejected.
|
||||
- `sandboxPolicy` accepts the same shape used by `turn/start` (e.g., `dangerFullAccess`, `readOnly`, `workspaceWrite` with flags).
|
||||
- When omitted, `timeoutMs` falls back to the server default.
|
||||
|
||||
## Events (work-in-progress)
|
||||
|
||||
@@ -244,6 +271,7 @@ The app-server streams JSON-RPC notifications while a turn is running. Each turn
|
||||
|
||||
- `turn/started` — `{ turn }` with the turn id, empty `items`, and `status: "inProgress"`.
|
||||
- `turn/completed` — `{ turn }` where `turn.status` is `completed`, `interrupted`, or `failed`; failures carry `{ error: { message, codexErrorInfo? } }`.
|
||||
- `turn/plan/updated` — `{ turnId, explanation?, plan }` whenever the agent shares or changes its plan; each `plan` entry is `{ step, status }` with `status` in `pending`, `inProgress`, or `completed`.
|
||||
|
||||
Today both notifications carry an empty `items` array even when item events were streamed; rely on `item/*` notifications for the canonical item list until this is fixed.
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ use codex_app_server_protocol::ContextCompactedNotification;
|
||||
use codex_app_server_protocol::ErrorNotification;
|
||||
use codex_app_server_protocol::ExecCommandApprovalParams;
|
||||
use codex_app_server_protocol::ExecCommandApprovalResponse;
|
||||
use codex_app_server_protocol::FileChangeOutputDeltaNotification;
|
||||
use codex_app_server_protocol::FileChangeRequestApprovalParams;
|
||||
use codex_app_server_protocol::FileChangeRequestApprovalResponse;
|
||||
use codex_app_server_protocol::FileUpdateChange;
|
||||
@@ -43,6 +44,8 @@ use codex_app_server_protocol::TurnCompletedNotification;
|
||||
use codex_app_server_protocol::TurnDiffUpdatedNotification;
|
||||
use codex_app_server_protocol::TurnError;
|
||||
use codex_app_server_protocol::TurnInterruptResponse;
|
||||
use codex_app_server_protocol::TurnPlanStep;
|
||||
use codex_app_server_protocol::TurnPlanUpdatedNotification;
|
||||
use codex_app_server_protocol::TurnStatus;
|
||||
use codex_core::CodexConversation;
|
||||
use codex_core::parse_command::shlex_join;
|
||||
@@ -59,7 +62,9 @@ use codex_core::protocol::ReviewDecision;
|
||||
use codex_core::protocol::TokenCountEvent;
|
||||
use codex_core::protocol::TurnDiffEvent;
|
||||
use codex_core::review_format::format_review_findings_block;
|
||||
use codex_core::review_prompts;
|
||||
use codex_protocol::ConversationId;
|
||||
use codex_protocol::plan_tool::UpdatePlanArgs;
|
||||
use codex_protocol::protocol::ReviewOutputEvent;
|
||||
use std::collections::HashMap;
|
||||
use std::convert::TryFrom;
|
||||
@@ -257,6 +262,8 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
}
|
||||
EventMsg::AgentMessageContentDelta(event) => {
|
||||
let notification = AgentMessageDeltaNotification {
|
||||
thread_id: conversation_id.to_string(),
|
||||
turn_id: event_turn_id.clone(),
|
||||
item_id: event.item_id,
|
||||
delta: event.delta,
|
||||
};
|
||||
@@ -275,6 +282,8 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
}
|
||||
EventMsg::ReasoningContentDelta(event) => {
|
||||
let notification = ReasoningSummaryTextDeltaNotification {
|
||||
thread_id: conversation_id.to_string(),
|
||||
turn_id: event_turn_id.clone(),
|
||||
item_id: event.item_id,
|
||||
delta: event.delta,
|
||||
summary_index: event.summary_index,
|
||||
@@ -287,6 +296,8 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
}
|
||||
EventMsg::ReasoningRawContentDelta(event) => {
|
||||
let notification = ReasoningTextDeltaNotification {
|
||||
thread_id: conversation_id.to_string(),
|
||||
turn_id: event_turn_id.clone(),
|
||||
item_id: event.item_id,
|
||||
delta: event.delta,
|
||||
content_index: event.content_index,
|
||||
@@ -297,6 +308,8 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
}
|
||||
EventMsg::AgentReasoningSectionBreak(event) => {
|
||||
let notification = ReasoningSummaryPartAddedNotification {
|
||||
thread_id: conversation_id.to_string(),
|
||||
turn_id: event_turn_id.clone(),
|
||||
item_id: event.item_id,
|
||||
summary_index: event.summary_index,
|
||||
};
|
||||
@@ -339,17 +352,51 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
}))
|
||||
.await;
|
||||
}
|
||||
EventMsg::EnteredReviewMode(review_request) => {
|
||||
let notification = ItemStartedNotification {
|
||||
EventMsg::ViewImageToolCall(view_image_event) => {
|
||||
let item = ThreadItem::ImageView {
|
||||
id: view_image_event.call_id.clone(),
|
||||
path: view_image_event.path.to_string_lossy().into_owned(),
|
||||
};
|
||||
let started = ItemStartedNotification {
|
||||
thread_id: conversation_id.to_string(),
|
||||
turn_id: event_turn_id.clone(),
|
||||
item: ThreadItem::CodeReview {
|
||||
id: event_turn_id.clone(),
|
||||
review: review_request.user_facing_hint,
|
||||
},
|
||||
item: item.clone(),
|
||||
};
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::ItemStarted(notification))
|
||||
.send_server_notification(ServerNotification::ItemStarted(started))
|
||||
.await;
|
||||
let completed = ItemCompletedNotification {
|
||||
thread_id: conversation_id.to_string(),
|
||||
turn_id: event_turn_id.clone(),
|
||||
item,
|
||||
};
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::ItemCompleted(completed))
|
||||
.await;
|
||||
}
|
||||
EventMsg::EnteredReviewMode(review_request) => {
|
||||
let review = review_request
|
||||
.user_facing_hint
|
||||
.unwrap_or_else(|| review_prompts::user_facing_hint(&review_request.target));
|
||||
let item = ThreadItem::EnteredReviewMode {
|
||||
id: event_turn_id.clone(),
|
||||
review,
|
||||
};
|
||||
let started = ItemStartedNotification {
|
||||
thread_id: conversation_id.to_string(),
|
||||
turn_id: event_turn_id.clone(),
|
||||
item: item.clone(),
|
||||
};
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::ItemStarted(started))
|
||||
.await;
|
||||
let completed = ItemCompletedNotification {
|
||||
thread_id: conversation_id.to_string(),
|
||||
turn_id: event_turn_id.clone(),
|
||||
item,
|
||||
};
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::ItemCompleted(completed))
|
||||
.await;
|
||||
}
|
||||
EventMsg::ItemStarted(item_started_event) => {
|
||||
@@ -375,21 +422,29 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
.await;
|
||||
}
|
||||
EventMsg::ExitedReviewMode(review_event) => {
|
||||
let review_text = match review_event.review_output {
|
||||
let review = match review_event.review_output {
|
||||
Some(output) => render_review_output_text(&output),
|
||||
None => REVIEW_FALLBACK_MESSAGE.to_string(),
|
||||
};
|
||||
let review_item_id = event_turn_id.clone();
|
||||
let notification = ItemCompletedNotification {
|
||||
let item = ThreadItem::ExitedReviewMode {
|
||||
id: event_turn_id.clone(),
|
||||
review,
|
||||
};
|
||||
let started = ItemStartedNotification {
|
||||
thread_id: conversation_id.to_string(),
|
||||
turn_id: event_turn_id.clone(),
|
||||
item: ThreadItem::CodeReview {
|
||||
id: review_item_id,
|
||||
review: review_text,
|
||||
},
|
||||
item: item.clone(),
|
||||
};
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::ItemCompleted(notification))
|
||||
.send_server_notification(ServerNotification::ItemStarted(started))
|
||||
.await;
|
||||
let completed = ItemCompletedNotification {
|
||||
thread_id: conversation_id.to_string(),
|
||||
turn_id: event_turn_id.clone(),
|
||||
item,
|
||||
};
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::ItemCompleted(completed))
|
||||
.await;
|
||||
}
|
||||
EventMsg::PatchApplyBegin(patch_begin_event) => {
|
||||
@@ -449,11 +504,13 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
.collect::<Vec<_>>();
|
||||
let command = shlex_join(&exec_command_begin_event.command);
|
||||
let cwd = exec_command_begin_event.cwd;
|
||||
let process_id = exec_command_begin_event.process_id;
|
||||
|
||||
let item = ThreadItem::CommandExecution {
|
||||
id: item_id,
|
||||
command,
|
||||
cwd,
|
||||
process_id,
|
||||
status: CommandExecutionStatus::InProgress,
|
||||
command_actions,
|
||||
aggregated_output: None,
|
||||
@@ -470,15 +527,44 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
.await;
|
||||
}
|
||||
EventMsg::ExecCommandOutputDelta(exec_command_output_delta_event) => {
|
||||
let notification = CommandExecutionOutputDeltaNotification {
|
||||
item_id: exec_command_output_delta_event.call_id.clone(),
|
||||
delta: String::from_utf8_lossy(&exec_command_output_delta_event.chunk).to_string(),
|
||||
let item_id = exec_command_output_delta_event.call_id.clone();
|
||||
let delta = String::from_utf8_lossy(&exec_command_output_delta_event.chunk).to_string();
|
||||
// The underlying EventMsg::ExecCommandOutputDelta is used for shell, unified_exec,
|
||||
// and apply_patch tool calls. We represent apply_patch with the FileChange item, and
|
||||
// everything else with the CommandExecution item.
|
||||
//
|
||||
// We need to detect which item type it is so we can emit the right notification.
|
||||
// We already have state tracking FileChange items on item/started, so let's use that.
|
||||
let is_file_change = {
|
||||
let map = turn_summary_store.lock().await;
|
||||
map.get(&conversation_id)
|
||||
.is_some_and(|summary| summary.file_change_started.contains(&item_id))
|
||||
};
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::CommandExecutionOutputDelta(
|
||||
notification,
|
||||
))
|
||||
.await;
|
||||
if is_file_change {
|
||||
let notification = FileChangeOutputDeltaNotification {
|
||||
thread_id: conversation_id.to_string(),
|
||||
turn_id: event_turn_id.clone(),
|
||||
item_id,
|
||||
delta,
|
||||
};
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::FileChangeOutputDelta(
|
||||
notification,
|
||||
))
|
||||
.await;
|
||||
} else {
|
||||
let notification = CommandExecutionOutputDeltaNotification {
|
||||
thread_id: conversation_id.to_string(),
|
||||
turn_id: event_turn_id.clone(),
|
||||
item_id,
|
||||
delta,
|
||||
};
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::CommandExecutionOutputDelta(
|
||||
notification,
|
||||
))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
EventMsg::ExecCommandEnd(exec_command_end_event) => {
|
||||
let ExecCommandEndEvent {
|
||||
@@ -486,6 +572,7 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
command,
|
||||
cwd,
|
||||
parsed_cmd,
|
||||
process_id,
|
||||
aggregated_output,
|
||||
exit_code,
|
||||
duration,
|
||||
@@ -514,6 +601,7 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
id: call_id,
|
||||
command: shlex_join(&command),
|
||||
cwd,
|
||||
process_id,
|
||||
status,
|
||||
command_actions,
|
||||
aggregated_output,
|
||||
@@ -563,6 +651,7 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
}
|
||||
EventMsg::TurnDiff(turn_diff_event) => {
|
||||
handle_turn_diff(
|
||||
conversation_id,
|
||||
&event_turn_id,
|
||||
turn_diff_event,
|
||||
api_version,
|
||||
@@ -570,12 +659,22 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
)
|
||||
.await;
|
||||
}
|
||||
EventMsg::PlanUpdate(plan_update_event) => {
|
||||
handle_turn_plan_update(
|
||||
&event_turn_id,
|
||||
plan_update_event,
|
||||
api_version,
|
||||
outgoing.as_ref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_turn_diff(
|
||||
conversation_id: ConversationId,
|
||||
event_turn_id: &str,
|
||||
turn_diff_event: TurnDiffEvent,
|
||||
api_version: ApiVersion,
|
||||
@@ -583,6 +682,7 @@ async fn handle_turn_diff(
|
||||
) {
|
||||
if let ApiVersion::V2 = api_version {
|
||||
let notification = TurnDiffUpdatedNotification {
|
||||
thread_id: conversation_id.to_string(),
|
||||
turn_id: event_turn_id.to_string(),
|
||||
diff: turn_diff_event.unified_diff,
|
||||
};
|
||||
@@ -592,10 +692,33 @@ async fn handle_turn_diff(
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_turn_plan_update(
|
||||
event_turn_id: &str,
|
||||
plan_update_event: UpdatePlanArgs,
|
||||
api_version: ApiVersion,
|
||||
outgoing: &OutgoingMessageSender,
|
||||
) {
|
||||
if let ApiVersion::V2 = api_version {
|
||||
let notification = TurnPlanUpdatedNotification {
|
||||
turn_id: event_turn_id.to_string(),
|
||||
explanation: plan_update_event.explanation,
|
||||
plan: plan_update_event
|
||||
.plan
|
||||
.into_iter()
|
||||
.map(TurnPlanStep::from)
|
||||
.collect(),
|
||||
};
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::TurnPlanUpdated(notification))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn emit_turn_completed_with_status(
|
||||
conversation_id: ConversationId,
|
||||
event_turn_id: String,
|
||||
status: TurnStatus,
|
||||
error: Option<TurnError>,
|
||||
outgoing: &OutgoingMessageSender,
|
||||
) {
|
||||
let notification = TurnCompletedNotification {
|
||||
@@ -603,6 +726,7 @@ async fn emit_turn_completed_with_status(
|
||||
turn: Turn {
|
||||
id: event_turn_id,
|
||||
items: vec![],
|
||||
error,
|
||||
status,
|
||||
},
|
||||
};
|
||||
@@ -649,6 +773,7 @@ async fn complete_command_execution_item(
|
||||
item_id: String,
|
||||
command: String,
|
||||
cwd: PathBuf,
|
||||
process_id: Option<String>,
|
||||
command_actions: Vec<V2ParsedCommand>,
|
||||
status: CommandExecutionStatus,
|
||||
outgoing: &OutgoingMessageSender,
|
||||
@@ -657,6 +782,7 @@ async fn complete_command_execution_item(
|
||||
id: item_id,
|
||||
command,
|
||||
cwd,
|
||||
process_id,
|
||||
status,
|
||||
command_actions,
|
||||
aggregated_output: None,
|
||||
@@ -689,13 +815,12 @@ async fn handle_turn_complete(
|
||||
) {
|
||||
let turn_summary = find_and_remove_turn_summary(conversation_id, turn_summary_store).await;
|
||||
|
||||
let status = if let Some(error) = turn_summary.last_error {
|
||||
TurnStatus::Failed { error }
|
||||
} else {
|
||||
TurnStatus::Completed
|
||||
let (status, error) = match turn_summary.last_error {
|
||||
Some(error) => (TurnStatus::Failed, Some(error)),
|
||||
None => (TurnStatus::Completed, None),
|
||||
};
|
||||
|
||||
emit_turn_completed_with_status(conversation_id, event_turn_id, status, outgoing).await;
|
||||
emit_turn_completed_with_status(conversation_id, event_turn_id, status, error, outgoing).await;
|
||||
}
|
||||
|
||||
async fn handle_turn_interrupted(
|
||||
@@ -710,6 +835,7 @@ async fn handle_turn_interrupted(
|
||||
conversation_id,
|
||||
event_turn_id,
|
||||
TurnStatus::Interrupted,
|
||||
None,
|
||||
outgoing,
|
||||
)
|
||||
.await;
|
||||
@@ -1015,6 +1141,7 @@ async fn on_command_execution_request_approval_response(
|
||||
item_id.clone(),
|
||||
command.clone(),
|
||||
cwd.clone(),
|
||||
None,
|
||||
command_actions.clone(),
|
||||
status,
|
||||
outgoing.as_ref(),
|
||||
@@ -1108,12 +1235,15 @@ mod tests {
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use anyhow::bail;
|
||||
use codex_app_server_protocol::TurnPlanStepStatus;
|
||||
use codex_core::protocol::CreditsSnapshot;
|
||||
use codex_core::protocol::McpInvocation;
|
||||
use codex_core::protocol::RateLimitSnapshot;
|
||||
use codex_core::protocol::RateLimitWindow;
|
||||
use codex_core::protocol::TokenUsage;
|
||||
use codex_core::protocol::TokenUsageInfo;
|
||||
use codex_protocol::plan_tool::PlanItemArg;
|
||||
use codex_protocol::plan_tool::StepStatus;
|
||||
use mcp_types::CallToolResult;
|
||||
use mcp_types::ContentBlock;
|
||||
use mcp_types::TextContent;
|
||||
@@ -1178,6 +1308,7 @@ mod tests {
|
||||
OutgoingMessage::AppServerNotification(ServerNotification::TurnCompleted(n)) => {
|
||||
assert_eq!(n.turn.id, event_turn_id);
|
||||
assert_eq!(n.turn.status, TurnStatus::Completed);
|
||||
assert_eq!(n.turn.error, None);
|
||||
}
|
||||
other => bail!("unexpected message: {other:?}"),
|
||||
}
|
||||
@@ -1218,6 +1349,7 @@ mod tests {
|
||||
OutgoingMessage::AppServerNotification(ServerNotification::TurnCompleted(n)) => {
|
||||
assert_eq!(n.turn.id, event_turn_id);
|
||||
assert_eq!(n.turn.status, TurnStatus::Interrupted);
|
||||
assert_eq!(n.turn.error, None);
|
||||
}
|
||||
other => bail!("unexpected message: {other:?}"),
|
||||
}
|
||||
@@ -1257,14 +1389,13 @@ mod tests {
|
||||
match msg {
|
||||
OutgoingMessage::AppServerNotification(ServerNotification::TurnCompleted(n)) => {
|
||||
assert_eq!(n.turn.id, event_turn_id);
|
||||
assert_eq!(n.turn.status, TurnStatus::Failed);
|
||||
assert_eq!(
|
||||
n.turn.status,
|
||||
TurnStatus::Failed {
|
||||
error: TurnError {
|
||||
message: "bad".to_string(),
|
||||
codex_error_info: Some(V2CodexErrorInfo::Other),
|
||||
}
|
||||
}
|
||||
n.turn.error,
|
||||
Some(TurnError {
|
||||
message: "bad".to_string(),
|
||||
codex_error_info: Some(V2CodexErrorInfo::Other),
|
||||
})
|
||||
);
|
||||
}
|
||||
other => bail!("unexpected message: {other:?}"),
|
||||
@@ -1273,6 +1404,46 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_handle_turn_plan_update_emits_notification_for_v2() -> Result<()> {
|
||||
let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY);
|
||||
let outgoing = OutgoingMessageSender::new(tx);
|
||||
let update = UpdatePlanArgs {
|
||||
explanation: Some("need plan".to_string()),
|
||||
plan: vec![
|
||||
PlanItemArg {
|
||||
step: "first".to_string(),
|
||||
status: StepStatus::Pending,
|
||||
},
|
||||
PlanItemArg {
|
||||
step: "second".to_string(),
|
||||
status: StepStatus::Completed,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
handle_turn_plan_update("turn-123", update, ApiVersion::V2, &outgoing).await;
|
||||
|
||||
let msg = rx
|
||||
.recv()
|
||||
.await
|
||||
.ok_or_else(|| anyhow!("should send one notification"))?;
|
||||
match msg {
|
||||
OutgoingMessage::AppServerNotification(ServerNotification::TurnPlanUpdated(n)) => {
|
||||
assert_eq!(n.turn_id, "turn-123");
|
||||
assert_eq!(n.explanation.as_deref(), Some("need plan"));
|
||||
assert_eq!(n.plan.len(), 2);
|
||||
assert_eq!(n.plan[0].step, "first");
|
||||
assert_eq!(n.plan[0].status, TurnPlanStepStatus::Pending);
|
||||
assert_eq!(n.plan[1].step, "second");
|
||||
assert_eq!(n.plan[1].status, TurnPlanStepStatus::Completed);
|
||||
}
|
||||
other => bail!("unexpected message: {other:?}"),
|
||||
}
|
||||
assert!(rx.try_recv().is_err(), "no extra messages expected");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_handle_token_count_event_emits_usage_and_rate_limits() -> Result<()> {
|
||||
let conversation_id = ConversationId::new();
|
||||
@@ -1485,14 +1656,13 @@ mod tests {
|
||||
match msg {
|
||||
OutgoingMessage::AppServerNotification(ServerNotification::TurnCompleted(n)) => {
|
||||
assert_eq!(n.turn.id, a_turn1);
|
||||
assert_eq!(n.turn.status, TurnStatus::Failed);
|
||||
assert_eq!(
|
||||
n.turn.status,
|
||||
TurnStatus::Failed {
|
||||
error: TurnError {
|
||||
message: "a1".to_string(),
|
||||
codex_error_info: Some(V2CodexErrorInfo::BadRequest),
|
||||
}
|
||||
}
|
||||
n.turn.error,
|
||||
Some(TurnError {
|
||||
message: "a1".to_string(),
|
||||
codex_error_info: Some(V2CodexErrorInfo::BadRequest),
|
||||
})
|
||||
);
|
||||
}
|
||||
other => bail!("unexpected message: {other:?}"),
|
||||
@@ -1506,14 +1676,13 @@ mod tests {
|
||||
match msg {
|
||||
OutgoingMessage::AppServerNotification(ServerNotification::TurnCompleted(n)) => {
|
||||
assert_eq!(n.turn.id, b_turn1);
|
||||
assert_eq!(n.turn.status, TurnStatus::Failed);
|
||||
assert_eq!(
|
||||
n.turn.status,
|
||||
TurnStatus::Failed {
|
||||
error: TurnError {
|
||||
message: "b1".to_string(),
|
||||
codex_error_info: None,
|
||||
}
|
||||
}
|
||||
n.turn.error,
|
||||
Some(TurnError {
|
||||
message: "b1".to_string(),
|
||||
codex_error_info: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
other => bail!("unexpected message: {other:?}"),
|
||||
@@ -1528,6 +1697,7 @@ mod tests {
|
||||
OutgoingMessage::AppServerNotification(ServerNotification::TurnCompleted(n)) => {
|
||||
assert_eq!(n.turn.id, a_turn2);
|
||||
assert_eq!(n.turn.status, TurnStatus::Completed);
|
||||
assert_eq!(n.turn.error, None);
|
||||
}
|
||||
other => bail!("unexpected message: {other:?}"),
|
||||
}
|
||||
@@ -1672,8 +1842,10 @@ mod tests {
|
||||
let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY);
|
||||
let outgoing = OutgoingMessageSender::new(tx);
|
||||
let unified_diff = "--- a\n+++ b\n".to_string();
|
||||
let conversation_id = ConversationId::new();
|
||||
|
||||
handle_turn_diff(
|
||||
conversation_id,
|
||||
"turn-1",
|
||||
TurnDiffEvent {
|
||||
unified_diff: unified_diff.clone(),
|
||||
@@ -1691,6 +1863,7 @@ mod tests {
|
||||
OutgoingMessage::AppServerNotification(ServerNotification::TurnDiffUpdated(
|
||||
notification,
|
||||
)) => {
|
||||
assert_eq!(notification.thread_id, conversation_id.to_string());
|
||||
assert_eq!(notification.turn_id, "turn-1");
|
||||
assert_eq!(notification.diff, unified_diff);
|
||||
}
|
||||
@@ -1704,8 +1877,10 @@ mod tests {
|
||||
async fn test_handle_turn_diff_is_noop_for_v1() -> Result<()> {
|
||||
let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY);
|
||||
let outgoing = OutgoingMessageSender::new(tx);
|
||||
let conversation_id = ConversationId::new();
|
||||
|
||||
handle_turn_diff(
|
||||
conversation_id,
|
||||
"turn-1",
|
||||
TurnDiffEvent {
|
||||
unified_diff: "diff".to_string(),
|
||||
|
||||
@@ -21,9 +21,9 @@ use codex_app_server_protocol::CancelLoginAccountParams;
|
||||
use codex_app_server_protocol::CancelLoginAccountResponse;
|
||||
use codex_app_server_protocol::CancelLoginChatGptResponse;
|
||||
use codex_app_server_protocol::ClientRequest;
|
||||
use codex_app_server_protocol::CommandExecParams;
|
||||
use codex_app_server_protocol::ConversationGitInfo;
|
||||
use codex_app_server_protocol::ConversationSummary;
|
||||
use codex_app_server_protocol::ExecOneOffCommandParams;
|
||||
use codex_app_server_protocol::ExecOneOffCommandResponse;
|
||||
use codex_app_server_protocol::FeedbackUploadParams;
|
||||
use codex_app_server_protocol::FeedbackUploadResponse;
|
||||
@@ -61,8 +61,10 @@ use codex_app_server_protocol::RemoveConversationSubscriptionResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ResumeConversationParams;
|
||||
use codex_app_server_protocol::ResumeConversationResponse;
|
||||
use codex_app_server_protocol::ReviewDelivery as ApiReviewDelivery;
|
||||
use codex_app_server_protocol::ReviewStartParams;
|
||||
use codex_app_server_protocol::ReviewTarget;
|
||||
use codex_app_server_protocol::ReviewStartResponse;
|
||||
use codex_app_server_protocol::ReviewTarget as ApiReviewTarget;
|
||||
use codex_app_server_protocol::SandboxMode;
|
||||
use codex_app_server_protocol::SendUserMessageParams;
|
||||
use codex_app_server_protocol::SendUserMessageResponse;
|
||||
@@ -120,7 +122,9 @@ use codex_core::git_info::git_diff_to_remote;
|
||||
use codex_core::parse_cursor;
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::ReviewDelivery as CoreReviewDelivery;
|
||||
use codex_core::protocol::ReviewRequest;
|
||||
use codex_core::protocol::ReviewTarget as CoreReviewTarget;
|
||||
use codex_core::protocol::SessionConfiguredEvent;
|
||||
use codex_core::read_head_for_summary;
|
||||
use codex_feedback::CodexFeedback;
|
||||
@@ -148,7 +152,6 @@ use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
use tokio::select;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::oneshot;
|
||||
use tracing::error;
|
||||
@@ -252,8 +255,7 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
|
||||
fn review_request_from_target(
|
||||
target: ReviewTarget,
|
||||
append_to_original_thread: bool,
|
||||
target: ApiReviewTarget,
|
||||
) -> Result<(ReviewRequest, String), JSONRPCErrorError> {
|
||||
fn invalid_request(message: String) -> JSONRPCErrorError {
|
||||
JSONRPCErrorError {
|
||||
@@ -263,77 +265,52 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
match target {
|
||||
// TODO(jif) those messages will be extracted in a follow-up PR.
|
||||
ReviewTarget::UncommittedChanges => Ok((
|
||||
ReviewRequest {
|
||||
prompt: "Review the current code changes (staged, unstaged, and untracked files) and provide prioritized findings.".to_string(),
|
||||
user_facing_hint: "current changes".to_string(),
|
||||
append_to_original_thread,
|
||||
},
|
||||
"Review uncommitted changes".to_string(),
|
||||
)),
|
||||
ReviewTarget::BaseBranch { branch } => {
|
||||
let cleaned_target = match target {
|
||||
ApiReviewTarget::UncommittedChanges => ApiReviewTarget::UncommittedChanges,
|
||||
ApiReviewTarget::BaseBranch { branch } => {
|
||||
let branch = branch.trim().to_string();
|
||||
if branch.is_empty() {
|
||||
return Err(invalid_request("branch must not be empty".to_string()));
|
||||
}
|
||||
let prompt = format!("Review the code changes against the base branch '{branch}'. Start by finding the merge diff between the current branch and {branch}'s upstream e.g. (`git merge-base HEAD \"$(git rev-parse --abbrev-ref \"{branch}@{{upstream}}\")\"`), then run `git diff` against that SHA to see what changes we would merge into the {branch} branch. Provide prioritized, actionable findings.");
|
||||
let hint = format!("changes against '{branch}'");
|
||||
let display = format!("Review changes against base branch '{branch}'");
|
||||
Ok((
|
||||
ReviewRequest {
|
||||
prompt,
|
||||
user_facing_hint: hint,
|
||||
append_to_original_thread,
|
||||
},
|
||||
display,
|
||||
))
|
||||
ApiReviewTarget::BaseBranch { branch }
|
||||
}
|
||||
ReviewTarget::Commit { sha, title } => {
|
||||
ApiReviewTarget::Commit { sha, title } => {
|
||||
let sha = sha.trim().to_string();
|
||||
if sha.is_empty() {
|
||||
return Err(invalid_request("sha must not be empty".to_string()));
|
||||
}
|
||||
let brief_title = title
|
||||
let title = title
|
||||
.map(|t| t.trim().to_string())
|
||||
.filter(|t| !t.is_empty());
|
||||
let prompt = if let Some(title) = brief_title.clone() {
|
||||
format!("Review the code changes introduced by commit {sha} (\"{title}\"). Provide prioritized, actionable findings.")
|
||||
} else {
|
||||
format!("Review the code changes introduced by commit {sha}. Provide prioritized, actionable findings.")
|
||||
};
|
||||
let short_sha = sha.chars().take(7).collect::<String>();
|
||||
let hint = format!("commit {short_sha}");
|
||||
let display = if let Some(title) = brief_title {
|
||||
format!("Review commit {short_sha}: {title}")
|
||||
} else {
|
||||
format!("Review commit {short_sha}")
|
||||
};
|
||||
Ok((
|
||||
ReviewRequest {
|
||||
prompt,
|
||||
user_facing_hint: hint,
|
||||
append_to_original_thread,
|
||||
},
|
||||
display,
|
||||
))
|
||||
ApiReviewTarget::Commit { sha, title }
|
||||
}
|
||||
ReviewTarget::Custom { instructions } => {
|
||||
ApiReviewTarget::Custom { instructions } => {
|
||||
let trimmed = instructions.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
return Err(invalid_request("instructions must not be empty".to_string()));
|
||||
return Err(invalid_request(
|
||||
"instructions must not be empty".to_string(),
|
||||
));
|
||||
}
|
||||
ApiReviewTarget::Custom {
|
||||
instructions: trimmed,
|
||||
}
|
||||
Ok((
|
||||
ReviewRequest {
|
||||
prompt: trimmed.clone(),
|
||||
user_facing_hint: trimmed.clone(),
|
||||
append_to_original_thread,
|
||||
},
|
||||
trimmed,
|
||||
))
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let core_target = match cleaned_target {
|
||||
ApiReviewTarget::UncommittedChanges => CoreReviewTarget::UncommittedChanges,
|
||||
ApiReviewTarget::BaseBranch { branch } => CoreReviewTarget::BaseBranch { branch },
|
||||
ApiReviewTarget::Commit { sha, title } => CoreReviewTarget::Commit { sha, title },
|
||||
ApiReviewTarget::Custom { instructions } => CoreReviewTarget::Custom { instructions },
|
||||
};
|
||||
|
||||
let hint = codex_core::review_prompts::user_facing_hint(&core_target);
|
||||
let review_request = ReviewRequest {
|
||||
target: core_target,
|
||||
user_facing_hint: Some(hint.clone()),
|
||||
};
|
||||
|
||||
Ok((review_request, hint))
|
||||
}
|
||||
|
||||
pub async fn process_request(&mut self, request: ClientRequest) {
|
||||
@@ -469,9 +446,12 @@ impl CodexMessageProcessor {
|
||||
ClientRequest::FuzzyFileSearch { request_id, params } => {
|
||||
self.fuzzy_file_search(request_id, params).await;
|
||||
}
|
||||
ClientRequest::ExecOneOffCommand { request_id, params } => {
|
||||
ClientRequest::OneOffCommandExec { request_id, params } => {
|
||||
self.exec_one_off_command(request_id, params).await;
|
||||
}
|
||||
ClientRequest::ExecOneOffCommand { request_id, params } => {
|
||||
self.exec_one_off_command(request_id, params.into()).await;
|
||||
}
|
||||
ClientRequest::ConfigRead { .. }
|
||||
| ClientRequest::ConfigValueWrite { .. }
|
||||
| ClientRequest::ConfigBatchWrite { .. } => {
|
||||
@@ -1156,7 +1136,7 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
async fn exec_one_off_command(&self, request_id: RequestId, params: ExecOneOffCommandParams) {
|
||||
async fn exec_one_off_command(&self, request_id: RequestId, params: CommandExecParams) {
|
||||
tracing::debug!("ExecOneOffCommand params: {params:?}");
|
||||
|
||||
if params.command.is_empty() {
|
||||
@@ -1171,7 +1151,9 @@ impl CodexMessageProcessor {
|
||||
|
||||
let cwd = params.cwd.unwrap_or_else(|| self.config.cwd.clone());
|
||||
let env = create_env(&self.config.shell_environment_policy);
|
||||
let timeout_ms = params.timeout_ms;
|
||||
let timeout_ms = params
|
||||
.timeout_ms
|
||||
.and_then(|timeout_ms| u64::try_from(timeout_ms).ok());
|
||||
let exec_params = ExecParams {
|
||||
command: params.command,
|
||||
cwd,
|
||||
@@ -1184,6 +1166,7 @@ impl CodexMessageProcessor {
|
||||
|
||||
let effective_policy = params
|
||||
.sandbox_policy
|
||||
.map(|policy| policy.to_core())
|
||||
.unwrap_or_else(|| self.config.sandbox_policy.clone());
|
||||
|
||||
let codex_linux_sandbox_exe = self.config.codex_linux_sandbox_exe.clone();
|
||||
@@ -2228,51 +2211,25 @@ impl CodexMessageProcessor {
|
||||
.await
|
||||
{
|
||||
info!("conversation {conversation_id} was active; shutting down");
|
||||
let conversation_clone = conversation.clone();
|
||||
let notify = Arc::new(tokio::sync::Notify::new());
|
||||
let notify_clone = notify.clone();
|
||||
// Do not wait on conversation.next_event(); the listener task already consumes
|
||||
// the stream. Request shutdown and ensure the rollout file is flushed before moving it.
|
||||
if let Err(err) = conversation.submit(Op::Shutdown).await {
|
||||
error!("failed to submit Shutdown to conversation {conversation_id}: {err}");
|
||||
}
|
||||
|
||||
// Establish the listener for ShutdownComplete before submitting
|
||||
// Shutdown so it is not missed.
|
||||
let is_shutdown = tokio::spawn(async move {
|
||||
// Create the notified future outside the loop to avoid losing notifications.
|
||||
let notified = notify_clone.notified();
|
||||
tokio::pin!(notified);
|
||||
loop {
|
||||
select! {
|
||||
_ = &mut notified => { break; }
|
||||
event = conversation_clone.next_event() => {
|
||||
match event {
|
||||
Ok(event) => {
|
||||
if matches!(event.msg, EventMsg::ShutdownComplete) { break; }
|
||||
}
|
||||
// Break on errors to avoid tight loops when the agent loop has exited.
|
||||
Err(_) => { break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
let flush_result =
|
||||
tokio::time::timeout(Duration::from_secs(5), conversation.flush_rollout()).await;
|
||||
match flush_result {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(err)) => {
|
||||
warn!(
|
||||
"conversation {conversation_id} rollout flush failed before archive: {err}"
|
||||
);
|
||||
}
|
||||
});
|
||||
// Request shutdown.
|
||||
match conversation.submit(Op::Shutdown).await {
|
||||
Ok(_) => {
|
||||
// Successfully submitted Shutdown; wait before proceeding.
|
||||
select! {
|
||||
_ = is_shutdown => {
|
||||
// Normal shutdown: proceed with archive.
|
||||
}
|
||||
_ = tokio::time::sleep(Duration::from_secs(10)) => {
|
||||
warn!("conversation {conversation_id} shutdown timed out; proceeding with archive");
|
||||
// Wake any waiter; use notify_waiters to avoid missing the signal.
|
||||
notify.notify_waiters();
|
||||
// Perhaps we lost a shutdown race, so let's continue to
|
||||
// clean up the .jsonl file.
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
error!("failed to submit Shutdown to conversation {conversation_id}: {err}");
|
||||
notify.notify_waiters();
|
||||
Err(_) => {
|
||||
warn!(
|
||||
"conversation {conversation_id} rollout flush timed out; proceeding with archive"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2284,7 +2241,8 @@ impl CodexMessageProcessor {
|
||||
.codex_home
|
||||
.join(codex_core::ARCHIVED_SESSIONS_SUBDIR);
|
||||
tokio::fs::create_dir_all(&archive_folder).await?;
|
||||
tokio::fs::rename(&canonical_rollout_path, &archive_folder.join(&file_name)).await?;
|
||||
let destination = archive_folder.join(&file_name);
|
||||
tokio::fs::rename(&canonical_rollout_path, &destination).await?;
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
@@ -2471,6 +2429,7 @@ impl CodexMessageProcessor {
|
||||
let turn = Turn {
|
||||
id: turn_id.clone(),
|
||||
items: vec![],
|
||||
error: None,
|
||||
status: TurnStatus::InProgress,
|
||||
};
|
||||
|
||||
@@ -2497,60 +2456,221 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
async fn review_start(&self, request_id: RequestId, params: ReviewStartParams) {
|
||||
fn build_review_turn(turn_id: String, display_text: &str) -> Turn {
|
||||
let items = if display_text.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![ThreadItem::UserMessage {
|
||||
id: turn_id.clone(),
|
||||
content: vec![V2UserInput::Text {
|
||||
text: display_text.to_string(),
|
||||
}],
|
||||
}]
|
||||
};
|
||||
|
||||
Turn {
|
||||
id: turn_id,
|
||||
items,
|
||||
error: None,
|
||||
status: TurnStatus::InProgress,
|
||||
}
|
||||
}
|
||||
|
||||
async fn emit_review_started(
|
||||
&self,
|
||||
request_id: &RequestId,
|
||||
turn: Turn,
|
||||
parent_thread_id: String,
|
||||
review_thread_id: String,
|
||||
) {
|
||||
let response = ReviewStartResponse {
|
||||
turn: turn.clone(),
|
||||
review_thread_id,
|
||||
};
|
||||
self.outgoing
|
||||
.send_response(request_id.clone(), response)
|
||||
.await;
|
||||
|
||||
let notif = TurnStartedNotification {
|
||||
thread_id: parent_thread_id,
|
||||
turn,
|
||||
};
|
||||
self.outgoing
|
||||
.send_server_notification(ServerNotification::TurnStarted(notif))
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn start_inline_review(
|
||||
&self,
|
||||
request_id: &RequestId,
|
||||
parent_conversation: Arc<CodexConversation>,
|
||||
review_request: ReviewRequest,
|
||||
display_text: &str,
|
||||
parent_thread_id: String,
|
||||
) -> std::result::Result<(), JSONRPCErrorError> {
|
||||
let turn_id = parent_conversation
|
||||
.submit(Op::Review { review_request })
|
||||
.await;
|
||||
|
||||
match turn_id {
|
||||
Ok(turn_id) => {
|
||||
let turn = Self::build_review_turn(turn_id, display_text);
|
||||
self.emit_review_started(
|
||||
request_id,
|
||||
turn,
|
||||
parent_thread_id.clone(),
|
||||
parent_thread_id,
|
||||
)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => Err(JSONRPCErrorError {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: format!("failed to start review: {err}"),
|
||||
data: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_detached_review(
|
||||
&mut self,
|
||||
request_id: &RequestId,
|
||||
parent_conversation_id: ConversationId,
|
||||
review_request: ReviewRequest,
|
||||
display_text: &str,
|
||||
) -> std::result::Result<(), JSONRPCErrorError> {
|
||||
let rollout_path = find_conversation_path_by_id_str(
|
||||
&self.config.codex_home,
|
||||
&parent_conversation_id.to_string(),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| JSONRPCErrorError {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: format!("failed to locate conversation id {parent_conversation_id}: {err}"),
|
||||
data: None,
|
||||
})?
|
||||
.ok_or_else(|| JSONRPCErrorError {
|
||||
code: INVALID_REQUEST_ERROR_CODE,
|
||||
message: format!("no rollout found for conversation id {parent_conversation_id}"),
|
||||
data: None,
|
||||
})?;
|
||||
|
||||
let mut config = self.config.as_ref().clone();
|
||||
config.model = self.config.review_model.clone();
|
||||
|
||||
let NewConversation {
|
||||
conversation_id,
|
||||
conversation,
|
||||
session_configured,
|
||||
..
|
||||
} = self
|
||||
.conversation_manager
|
||||
.fork_conversation(usize::MAX, config, rollout_path)
|
||||
.await
|
||||
.map_err(|err| JSONRPCErrorError {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: format!("error creating detached review conversation: {err}"),
|
||||
data: None,
|
||||
})?;
|
||||
|
||||
if let Err(err) = self
|
||||
.attach_conversation_listener(conversation_id, false, ApiVersion::V2)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
"failed to attach listener for review conversation {}: {}",
|
||||
conversation_id,
|
||||
err.message
|
||||
);
|
||||
}
|
||||
|
||||
let rollout_path = conversation.rollout_path();
|
||||
let fallback_provider = self.config.model_provider_id.as_str();
|
||||
match read_summary_from_rollout(rollout_path.as_path(), fallback_provider).await {
|
||||
Ok(summary) => {
|
||||
let thread = summary_to_thread(summary);
|
||||
let notif = ThreadStartedNotification { thread };
|
||||
self.outgoing
|
||||
.send_server_notification(ServerNotification::ThreadStarted(notif))
|
||||
.await;
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
"failed to load summary for review conversation {}: {}",
|
||||
session_configured.session_id,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let turn_id = conversation
|
||||
.submit(Op::Review { review_request })
|
||||
.await
|
||||
.map_err(|err| JSONRPCErrorError {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: format!("failed to start detached review turn: {err}"),
|
||||
data: None,
|
||||
})?;
|
||||
|
||||
let turn = Self::build_review_turn(turn_id, display_text);
|
||||
let review_thread_id = conversation_id.to_string();
|
||||
self.emit_review_started(request_id, turn, review_thread_id.clone(), review_thread_id)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn review_start(&mut self, request_id: RequestId, params: ReviewStartParams) {
|
||||
let ReviewStartParams {
|
||||
thread_id,
|
||||
target,
|
||||
append_to_original_thread,
|
||||
delivery,
|
||||
} = params;
|
||||
let (_, conversation) = match self.conversation_from_thread_id(&thread_id).await {
|
||||
Ok(v) => v,
|
||||
Err(error) => {
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let (review_request, display_text) =
|
||||
match Self::review_request_from_target(target, append_to_original_thread) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
self.outgoing.send_error(request_id, err).await;
|
||||
let (parent_conversation_id, parent_conversation) =
|
||||
match self.conversation_from_thread_id(&thread_id).await {
|
||||
Ok(v) => v,
|
||||
Err(error) => {
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let turn_id = conversation.submit(Op::Review { review_request }).await;
|
||||
|
||||
match turn_id {
|
||||
Ok(turn_id) => {
|
||||
let mut items = Vec::new();
|
||||
if !display_text.is_empty() {
|
||||
items.push(ThreadItem::UserMessage {
|
||||
id: turn_id.clone(),
|
||||
content: vec![V2UserInput::Text { text: display_text }],
|
||||
});
|
||||
}
|
||||
let turn = Turn {
|
||||
id: turn_id.clone(),
|
||||
items,
|
||||
status: TurnStatus::InProgress,
|
||||
};
|
||||
let response = TurnStartResponse { turn: turn.clone() };
|
||||
self.outgoing.send_response(request_id, response).await;
|
||||
|
||||
let notif = TurnStartedNotification { thread_id, turn };
|
||||
self.outgoing
|
||||
.send_server_notification(ServerNotification::TurnStarted(notif))
|
||||
.await;
|
||||
}
|
||||
let (review_request, display_text) = match Self::review_request_from_target(target) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
let error = JSONRPCErrorError {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: format!("failed to start review: {err}"),
|
||||
data: None,
|
||||
};
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
self.outgoing.send_error(request_id, err).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let delivery = delivery.unwrap_or(ApiReviewDelivery::Inline).to_core();
|
||||
match delivery {
|
||||
CoreReviewDelivery::Inline => {
|
||||
if let Err(err) = self
|
||||
.start_inline_review(
|
||||
&request_id,
|
||||
parent_conversation,
|
||||
review_request,
|
||||
display_text.as_str(),
|
||||
thread_id.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
self.outgoing.send_error(request_id, err).await;
|
||||
}
|
||||
}
|
||||
CoreReviewDelivery::Detached => {
|
||||
if let Err(err) = self
|
||||
.start_detached_review(
|
||||
&request_id,
|
||||
parent_conversation_id,
|
||||
review_request,
|
||||
display_text.as_str(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
self.outgoing.send_error(request_id, err).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ pub use mcp_process::McpProcess;
|
||||
pub use mock_model_server::create_mock_chat_completions_server;
|
||||
pub use mock_model_server::create_mock_chat_completions_server_unchecked;
|
||||
pub use responses::create_apply_patch_sse_response;
|
||||
pub use responses::create_exec_command_sse_response;
|
||||
pub use responses::create_final_assistant_message_sse_response;
|
||||
pub use responses::create_shell_command_sse_response;
|
||||
pub use rollout::create_fake_rollout;
|
||||
|
||||
@@ -94,3 +94,42 @@ pub fn create_apply_patch_sse_response(
|
||||
);
|
||||
Ok(sse)
|
||||
}
|
||||
|
||||
pub fn create_exec_command_sse_response(call_id: &str) -> anyhow::Result<String> {
|
||||
let (cmd, args) = if cfg!(windows) {
|
||||
("cmd.exe", vec!["/d", "/c", "echo hi"])
|
||||
} else {
|
||||
("/bin/sh", vec!["-c", "echo hi"])
|
||||
};
|
||||
let command = std::iter::once(cmd.to_string())
|
||||
.chain(args.into_iter().map(str::to_string))
|
||||
.collect::<Vec<_>>();
|
||||
let tool_call_arguments = serde_json::to_string(&json!({
|
||||
"cmd": command.join(" "),
|
||||
"yield_time_ms": 500
|
||||
}))?;
|
||||
let tool_call = json!({
|
||||
"choices": [
|
||||
{
|
||||
"delta": {
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": call_id,
|
||||
"function": {
|
||||
"name": "exec_command",
|
||||
"arguments": tool_call_arguments
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"finish_reason": "tool_calls"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let sse = format!(
|
||||
"data: {}\n\ndata: DONE\n\n",
|
||||
serde_json::to_string(&tool_call)?
|
||||
);
|
||||
Ok(sse)
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ pub fn create_fake_rollout(
|
||||
instructions: None,
|
||||
source: SessionSource::Cli,
|
||||
model_provider: model_provider.map(str::to_string),
|
||||
name: None,
|
||||
};
|
||||
let payload = serde_json::to_value(SessionMetaLine {
|
||||
meta,
|
||||
|
||||
@@ -9,12 +9,13 @@ use codex_app_server_protocol::JSONRPCError;
|
||||
use codex_app_server_protocol::JSONRPCNotification;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ReviewDelivery;
|
||||
use codex_app_server_protocol::ReviewStartParams;
|
||||
use codex_app_server_protocol::ReviewStartResponse;
|
||||
use codex_app_server_protocol::ReviewTarget;
|
||||
use codex_app_server_protocol::ThreadItem;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_app_server_protocol::ThreadStartResponse;
|
||||
use codex_app_server_protocol::TurnStartResponse;
|
||||
use codex_app_server_protocol::TurnStatus;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
@@ -59,7 +60,7 @@ async fn review_start_runs_review_turn_and_emits_code_review_item() -> Result<()
|
||||
let review_req = mcp
|
||||
.send_review_start_request(ReviewStartParams {
|
||||
thread_id: thread_id.clone(),
|
||||
append_to_original_thread: true,
|
||||
delivery: Some(ReviewDelivery::Inline),
|
||||
target: ReviewTarget::Commit {
|
||||
sha: "1234567deadbeef".to_string(),
|
||||
title: Some("Tidy UI colors".to_string()),
|
||||
@@ -71,43 +72,43 @@ async fn review_start_runs_review_turn_and_emits_code_review_item() -> Result<()
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(review_req)),
|
||||
)
|
||||
.await??;
|
||||
let TurnStartResponse { turn } = to_response::<TurnStartResponse>(review_resp)?;
|
||||
let ReviewStartResponse {
|
||||
turn,
|
||||
review_thread_id,
|
||||
} = to_response::<ReviewStartResponse>(review_resp)?;
|
||||
assert_eq!(review_thread_id, thread_id.clone());
|
||||
let turn_id = turn.id.clone();
|
||||
assert_eq!(turn.status, TurnStatus::InProgress);
|
||||
assert_eq!(turn.items.len(), 1);
|
||||
match &turn.items[0] {
|
||||
ThreadItem::UserMessage { content, .. } => {
|
||||
assert_eq!(content.len(), 1);
|
||||
assert!(matches!(
|
||||
&content[0],
|
||||
codex_app_server_protocol::UserInput::Text { .. }
|
||||
));
|
||||
}
|
||||
other => panic!("expected user message, got {other:?}"),
|
||||
}
|
||||
|
||||
let _started: JSONRPCNotification = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("turn/started"),
|
||||
)
|
||||
.await??;
|
||||
let item_started: JSONRPCNotification = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("item/started"),
|
||||
)
|
||||
.await??;
|
||||
let started: ItemStartedNotification =
|
||||
serde_json::from_value(item_started.params.expect("params must be present"))?;
|
||||
match started.item {
|
||||
ThreadItem::CodeReview { id, review } => {
|
||||
assert_eq!(id, turn_id);
|
||||
assert_eq!(review, "commit 1234567");
|
||||
// Confirm we see the EnteredReviewMode marker on the main thread.
|
||||
let mut saw_entered_review_mode = false;
|
||||
for _ in 0..10 {
|
||||
let item_started: JSONRPCNotification = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("item/started"),
|
||||
)
|
||||
.await??;
|
||||
let started: ItemStartedNotification =
|
||||
serde_json::from_value(item_started.params.expect("params must be present"))?;
|
||||
match started.item {
|
||||
ThreadItem::EnteredReviewMode { id, review } => {
|
||||
assert_eq!(id, turn_id);
|
||||
assert_eq!(review, "commit 1234567: Tidy UI colors");
|
||||
saw_entered_review_mode = true;
|
||||
break;
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
other => panic!("expected code review item, got {other:?}"),
|
||||
}
|
||||
assert!(
|
||||
saw_entered_review_mode,
|
||||
"did not observe enteredReviewMode item"
|
||||
);
|
||||
|
||||
// Confirm we see the ExitedReviewMode marker (with review text)
|
||||
// on the same turn. Ignore any other items the stream surfaces.
|
||||
let mut review_body: Option<String> = None;
|
||||
for _ in 0..5 {
|
||||
for _ in 0..10 {
|
||||
let review_notif: JSONRPCNotification = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("item/completed"),
|
||||
@@ -116,13 +117,12 @@ async fn review_start_runs_review_turn_and_emits_code_review_item() -> Result<()
|
||||
let completed: ItemCompletedNotification =
|
||||
serde_json::from_value(review_notif.params.expect("params must be present"))?;
|
||||
match completed.item {
|
||||
ThreadItem::CodeReview { id, review } => {
|
||||
ThreadItem::ExitedReviewMode { id, review } => {
|
||||
assert_eq!(id, turn_id);
|
||||
review_body = Some(review);
|
||||
break;
|
||||
}
|
||||
ThreadItem::UserMessage { .. } => continue,
|
||||
other => panic!("unexpected item/completed payload: {other:?}"),
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ async fn review_start_rejects_empty_base_branch() -> Result<()> {
|
||||
let request_id = mcp
|
||||
.send_review_start_request(ReviewStartParams {
|
||||
thread_id,
|
||||
append_to_original_thread: true,
|
||||
delivery: Some(ReviewDelivery::Inline),
|
||||
target: ReviewTarget::BaseBranch {
|
||||
branch: " ".to_string(),
|
||||
},
|
||||
@@ -167,6 +167,56 @@ async fn review_start_rejects_empty_base_branch() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn review_start_with_detached_delivery_returns_new_thread_id() -> Result<()> {
|
||||
let review_payload = json!({
|
||||
"findings": [],
|
||||
"overall_correctness": "ok",
|
||||
"overall_explanation": "detached review",
|
||||
"overall_confidence_score": 0.5
|
||||
})
|
||||
.to_string();
|
||||
let responses = vec![create_final_assistant_message_sse_response(
|
||||
&review_payload,
|
||||
)?];
|
||||
let server = create_mock_chat_completions_server_unchecked(responses).await;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), &server.uri())?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let thread_id = start_default_thread(&mut mcp).await?;
|
||||
|
||||
let review_req = mcp
|
||||
.send_review_start_request(ReviewStartParams {
|
||||
thread_id: thread_id.clone(),
|
||||
delivery: Some(ReviewDelivery::Detached),
|
||||
target: ReviewTarget::Custom {
|
||||
instructions: "detached review".to_string(),
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
let review_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(review_req)),
|
||||
)
|
||||
.await??;
|
||||
let ReviewStartResponse {
|
||||
turn,
|
||||
review_thread_id,
|
||||
} = to_response::<ReviewStartResponse>(review_resp)?;
|
||||
|
||||
assert_eq!(turn.status, TurnStatus::InProgress);
|
||||
assert_ne!(
|
||||
review_thread_id, thread_id,
|
||||
"detached review should run on a different thread"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn review_start_rejects_empty_commit_sha() -> Result<()> {
|
||||
let server = create_mock_chat_completions_server_unchecked(vec![]).await;
|
||||
@@ -180,7 +230,7 @@ async fn review_start_rejects_empty_commit_sha() -> Result<()> {
|
||||
let request_id = mcp
|
||||
.send_review_start_request(ReviewStartParams {
|
||||
thread_id,
|
||||
append_to_original_thread: true,
|
||||
delivery: Some(ReviewDelivery::Inline),
|
||||
target: ReviewTarget::Commit {
|
||||
sha: "\t".to_string(),
|
||||
title: None,
|
||||
@@ -215,7 +265,7 @@ async fn review_start_rejects_empty_custom_instructions() -> Result<()> {
|
||||
let request_id = mcp
|
||||
.send_review_start_request(ReviewStartParams {
|
||||
thread_id,
|
||||
append_to_original_thread: true,
|
||||
delivery: Some(ReviewDelivery::Inline),
|
||||
target: ReviewTarget::Custom {
|
||||
instructions: "\n\n".to_string(),
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use anyhow::Result;
|
||||
use app_test_support::McpProcess;
|
||||
use app_test_support::create_apply_patch_sse_response;
|
||||
use app_test_support::create_exec_command_sse_response;
|
||||
use app_test_support::create_final_assistant_message_sse_response;
|
||||
use app_test_support::create_mock_chat_completions_server;
|
||||
use app_test_support::create_mock_chat_completions_server_unchecked;
|
||||
@@ -10,6 +11,7 @@ use app_test_support::to_response;
|
||||
use codex_app_server_protocol::ApprovalDecision;
|
||||
use codex_app_server_protocol::CommandExecutionRequestApprovalResponse;
|
||||
use codex_app_server_protocol::CommandExecutionStatus;
|
||||
use codex_app_server_protocol::FileChangeOutputDeltaNotification;
|
||||
use codex_app_server_protocol::FileChangeRequestApprovalResponse;
|
||||
use codex_app_server_protocol::ItemCompletedNotification;
|
||||
use codex_app_server_protocol::ItemStartedNotification;
|
||||
@@ -724,6 +726,26 @@ async fn turn_start_file_change_approval_v2() -> Result<()> {
|
||||
)
|
||||
.await?;
|
||||
|
||||
let output_delta_notif = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("item/fileChange/outputDelta"),
|
||||
)
|
||||
.await??;
|
||||
let output_delta: FileChangeOutputDeltaNotification = serde_json::from_value(
|
||||
output_delta_notif
|
||||
.params
|
||||
.clone()
|
||||
.expect("item/fileChange/outputDelta params"),
|
||||
)?;
|
||||
assert_eq!(output_delta.thread_id, thread.id);
|
||||
assert_eq!(output_delta.turn_id, turn.id);
|
||||
assert_eq!(output_delta.item_id, "patch-call");
|
||||
assert!(
|
||||
!output_delta.delta.is_empty(),
|
||||
"expected delta to be non-empty, got: {}",
|
||||
output_delta.delta
|
||||
);
|
||||
|
||||
let completed_file_change = timeout(DEFAULT_READ_TIMEOUT, async {
|
||||
loop {
|
||||
let completed_notif = mcp
|
||||
@@ -907,6 +929,134 @@ async fn turn_start_file_change_approval_decline_v2() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[cfg_attr(windows, ignore = "process id reporting differs on Windows")]
|
||||
async fn command_execution_notifications_include_process_id() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let responses = vec![
|
||||
create_exec_command_sse_response("uexec-1")?,
|
||||
create_final_assistant_message_sse_response("done")?,
|
||||
];
|
||||
let server = create_mock_chat_completions_server(responses).await;
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), &server.uri(), "never")?;
|
||||
let config_toml = codex_home.path().join("config.toml");
|
||||
let mut config_contents = std::fs::read_to_string(&config_toml)?;
|
||||
config_contents.push_str(
|
||||
r#"
|
||||
[features]
|
||||
unified_exec = true
|
||||
"#,
|
||||
);
|
||||
std::fs::write(&config_toml, config_contents)?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let start_id = mcp
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
model: Some("mock-model".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let start_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(start_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(start_resp)?;
|
||||
|
||||
let turn_id = mcp
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
thread_id: thread.id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "run a command".to_string(),
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let turn_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(turn_id)),
|
||||
)
|
||||
.await??;
|
||||
let TurnStartResponse { turn: _turn } = to_response::<TurnStartResponse>(turn_resp)?;
|
||||
|
||||
let started_command = timeout(DEFAULT_READ_TIMEOUT, async {
|
||||
loop {
|
||||
let notif = mcp
|
||||
.read_stream_until_notification_message("item/started")
|
||||
.await?;
|
||||
let started: ItemStartedNotification = serde_json::from_value(
|
||||
notif
|
||||
.params
|
||||
.clone()
|
||||
.expect("item/started should include params"),
|
||||
)?;
|
||||
if let ThreadItem::CommandExecution { .. } = started.item {
|
||||
return Ok::<ThreadItem, anyhow::Error>(started.item);
|
||||
}
|
||||
}
|
||||
})
|
||||
.await??;
|
||||
let ThreadItem::CommandExecution {
|
||||
id,
|
||||
process_id: started_process_id,
|
||||
status,
|
||||
..
|
||||
} = started_command
|
||||
else {
|
||||
unreachable!("loop ensures we break on command execution items");
|
||||
};
|
||||
assert_eq!(id, "uexec-1");
|
||||
assert_eq!(status, CommandExecutionStatus::InProgress);
|
||||
let started_process_id = started_process_id.expect("process id should be present");
|
||||
|
||||
let completed_command = timeout(DEFAULT_READ_TIMEOUT, async {
|
||||
loop {
|
||||
let notif = mcp
|
||||
.read_stream_until_notification_message("item/completed")
|
||||
.await?;
|
||||
let completed: ItemCompletedNotification = serde_json::from_value(
|
||||
notif
|
||||
.params
|
||||
.clone()
|
||||
.expect("item/completed should include params"),
|
||||
)?;
|
||||
if let ThreadItem::CommandExecution { .. } = completed.item {
|
||||
return Ok::<ThreadItem, anyhow::Error>(completed.item);
|
||||
}
|
||||
}
|
||||
})
|
||||
.await??;
|
||||
let ThreadItem::CommandExecution {
|
||||
id: completed_id,
|
||||
process_id: completed_process_id,
|
||||
status: completed_status,
|
||||
exit_code,
|
||||
..
|
||||
} = completed_command
|
||||
else {
|
||||
unreachable!("loop ensures we break on command execution items");
|
||||
};
|
||||
assert_eq!(completed_id, "uexec-1");
|
||||
assert_eq!(completed_status, CommandExecutionStatus::Completed);
|
||||
assert_eq!(exit_code, Some(0));
|
||||
assert_eq!(
|
||||
completed_process_id.as_deref(),
|
||||
Some(started_process_id.as_str())
|
||||
);
|
||||
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("turn/completed"),
|
||||
)
|
||||
.await??;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Helper to create a config.toml pointing at the mock model server.
|
||||
fn create_config_toml(
|
||||
codex_home: &Path,
|
||||
|
||||
@@ -18,6 +18,8 @@ use codex_cli::login::run_logout;
|
||||
use codex_cloud_tasks::Cli as CloudTasksCli;
|
||||
use codex_common::CliConfigOverrides;
|
||||
use codex_exec::Cli as ExecCli;
|
||||
use codex_exec::Command as ExecCommand;
|
||||
use codex_exec::ReviewArgs;
|
||||
use codex_execpolicy::ExecPolicyCheckCommand;
|
||||
use codex_responses_api_proxy::Args as ResponsesApiProxyArgs;
|
||||
use codex_tui::AppExitInfo;
|
||||
@@ -72,6 +74,9 @@ enum Subcommand {
|
||||
#[clap(visible_alias = "e")]
|
||||
Exec(ExecCli),
|
||||
|
||||
/// Run a code review non-interactively.
|
||||
Review(ReviewArgs),
|
||||
|
||||
/// Manage login.
|
||||
Login(LoginCommand),
|
||||
|
||||
@@ -105,6 +110,9 @@ enum Subcommand {
|
||||
/// Resume a previous interactive session (picker by default; use --last to continue the most recent).
|
||||
Resume(ResumeCommand),
|
||||
|
||||
/// Fork an existing session into a new conversation.
|
||||
Fork(ForkCommand),
|
||||
|
||||
/// [EXPERIMENTAL] Browse tasks from Codex Cloud and apply changes locally.
|
||||
#[clap(name = "cloud", alias = "cloud-tasks")]
|
||||
Cloud(CloudTasksCli),
|
||||
@@ -147,6 +155,16 @@ struct ResumeCommand {
|
||||
config_overrides: TuiCli,
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct ForkCommand {
|
||||
/// Resume from a saved session name or rollout id, but start a new conversation.
|
||||
#[arg(value_name = "ID|NAME")]
|
||||
target: String,
|
||||
|
||||
#[clap(flatten)]
|
||||
config_overrides: TuiCli,
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct SandboxArgs {
|
||||
#[command(subcommand)]
|
||||
@@ -449,6 +467,15 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
|
||||
);
|
||||
codex_exec::run_main(exec_cli, codex_linux_sandbox_exe).await?;
|
||||
}
|
||||
Some(Subcommand::Review(review_args)) => {
|
||||
let mut exec_cli = ExecCli::try_parse_from(["codex", "exec"])?;
|
||||
exec_cli.command = Some(ExecCommand::Review(review_args));
|
||||
prepend_config_flags(
|
||||
&mut exec_cli.config_overrides,
|
||||
root_config_overrides.clone(),
|
||||
);
|
||||
codex_exec::run_main(exec_cli, codex_linux_sandbox_exe).await?;
|
||||
}
|
||||
Some(Subcommand::McpServer) => {
|
||||
codex_mcp_server::run_main(codex_linux_sandbox_exe, root_config_overrides).await?;
|
||||
}
|
||||
@@ -488,6 +515,19 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
|
||||
let exit_info = codex_tui::run_main(interactive, codex_linux_sandbox_exe).await?;
|
||||
handle_app_exit(exit_info)?;
|
||||
}
|
||||
Some(Subcommand::Fork(ForkCommand {
|
||||
target,
|
||||
config_overrides,
|
||||
})) => {
|
||||
interactive = finalize_fork_interactive(
|
||||
interactive,
|
||||
root_config_overrides.clone(),
|
||||
target,
|
||||
config_overrides,
|
||||
);
|
||||
let exit_info = codex_tui::run_main(interactive, codex_linux_sandbox_exe).await?;
|
||||
handle_app_exit(exit_info)?;
|
||||
}
|
||||
Some(Subcommand::Login(mut login_cli)) => {
|
||||
prepend_config_flags(
|
||||
&mut login_cli.config_overrides,
|
||||
@@ -652,6 +692,7 @@ fn finalize_resume_interactive(
|
||||
interactive.resume_last = last;
|
||||
interactive.resume_session_id = resume_session_id;
|
||||
interactive.resume_show_all = show_all;
|
||||
interactive.fork_source = None;
|
||||
|
||||
// Merge resume-scoped flags and overrides with highest precedence.
|
||||
merge_resume_cli_flags(&mut interactive, resume_cli);
|
||||
@@ -662,6 +703,21 @@ fn finalize_resume_interactive(
|
||||
interactive
|
||||
}
|
||||
|
||||
fn finalize_fork_interactive(
|
||||
mut interactive: TuiCli,
|
||||
root_config_overrides: CliConfigOverrides,
|
||||
target: String,
|
||||
fork_cli: TuiCli,
|
||||
) -> TuiCli {
|
||||
interactive.resume_picker = false;
|
||||
interactive.resume_last = false;
|
||||
interactive.resume_session_id = None;
|
||||
interactive.fork_source = Some(target);
|
||||
merge_resume_cli_flags(&mut interactive, fork_cli);
|
||||
prepend_config_flags(&mut interactive.config_overrides, root_config_overrides);
|
||||
interactive
|
||||
}
|
||||
|
||||
/// Merge flags provided to `codex resume` so they take precedence over any
|
||||
/// root-level flags. Only overrides fields explicitly set on the resume-scoped
|
||||
/// CLI. Also appends `-c key=value` overrides with highest precedence.
|
||||
@@ -752,6 +808,26 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
fn fork_from_args(args: &[&str]) -> TuiCli {
|
||||
let cli = MultitoolCli::try_parse_from(args).expect("parse");
|
||||
let MultitoolCli {
|
||||
interactive,
|
||||
config_overrides: root_overrides,
|
||||
subcommand,
|
||||
feature_toggles: _,
|
||||
} = cli;
|
||||
|
||||
let Subcommand::Fork(ForkCommand {
|
||||
target,
|
||||
config_overrides,
|
||||
}) = subcommand.expect("fork present")
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
|
||||
finalize_fork_interactive(interactive, root_overrides, target, config_overrides)
|
||||
}
|
||||
|
||||
fn sample_exit_info(conversation: Option<&str>) -> AppExitInfo {
|
||||
let token_usage = TokenUsage {
|
||||
output_tokens: 2,
|
||||
@@ -844,6 +920,15 @@ mod tests {
|
||||
assert!(interactive.resume_show_all);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fork_sets_target_and_disables_resume_controls() {
|
||||
let interactive = fork_from_args(["codex", "fork", "saved"].as_ref());
|
||||
assert_eq!(interactive.fork_source.as_deref(), Some("saved"));
|
||||
assert!(!interactive.resume_picker);
|
||||
assert!(!interactive.resume_last);
|
||||
assert!(interactive.resume_session_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_merges_option_flags_and_full_auto() {
|
||||
let interactive = finalize_from_args(
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
# Client Extraction Plan
|
||||
|
||||
## Goals
|
||||
- Split the HTTP transport/client code out of `codex-core` into a reusable crate that is agnostic of Codex/OpenAI business logic and API schemas.
|
||||
- Create a separate API library crate that houses typed requests/responses for well-known APIs (Responses, Chat Completions, Compact) and plugs into the transport crate via minimal traits.
|
||||
- Preserve current behaviour (auth headers, retries, SSE handling, rate-limit parsing, compaction, fixtures) while making the APIs symmetric and avoiding code duplication.
|
||||
- Keep existing consumers (`codex-core`, tests, and tools) stable by providing a small compatibility layer during the transition.
|
||||
|
||||
## Snapshot of Today
|
||||
- `core/src/client.rs (ModelClient)` owns config/auth/session state, chooses wire API, builds payloads, drives retries, parses SSE, compaction, and rate-limit headers.
|
||||
- `core/src/chat_completions.rs` implements the Chat Completions call + SSE parser + aggregation helper.
|
||||
- `core/src/client_common.rs` holds `Prompt`, tool specs, shared request structs (`ResponsesApiRequest`, `TextControls`), and `ResponseEvent`/`ResponseStream`.
|
||||
- `core/src/default_client.rs` wraps `reqwest` with Codex UA/originator defaults.
|
||||
- `core/src/model_provider_info.rs` models providers (base URL, headers, env keys, retry/timeout tuning) and builds `CodexRequestBuilder`s.
|
||||
- Current retry logic is co-located with API handling; streaming SSE parsing is duplicated across Responses/Chat.
|
||||
|
||||
## Target Crates (with interfaces)
|
||||
|
||||
- `codex-client` (generic transport)
|
||||
- Owns the generic HTTP machinery: a `CodexHttpClient`/`CodexRequestBuilder`-style wrapper, retry/backoff hooks, streaming connector (SSE framing + idle timeout), header injection, and optional telemetry callbacks.
|
||||
- Does **not** know about OpenAI/Codex-specific paths, headers, or error codes; it only exposes HTTP-level concepts (status, headers, bodies, connection errors).
|
||||
- Minimal surface:
|
||||
```rust
|
||||
pub trait HttpTransport {
|
||||
fn execute(&self, req: Request) -> Result<Response, TransportError>;
|
||||
fn stream(&self, req: Request) -> Result<ByteStream, TransportError>;
|
||||
}
|
||||
|
||||
pub struct Request {
|
||||
pub method: Method,
|
||||
pub url: String,
|
||||
pub headers: HeaderMap,
|
||||
pub body: Option<serde_json::Value>,
|
||||
pub timeout: Option<Duration>,
|
||||
}
|
||||
```
|
||||
- Generic client traits (request/response/chunk are abstract over the transport):
|
||||
```rust
|
||||
#[async_trait::async_trait]
|
||||
pub trait UnaryClient<Req, Resp> {
|
||||
async fn run(&self, req: Req) -> Result<Resp, TransportError>;
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait StreamClient<Req, Chunk> {
|
||||
async fn run(&self, req: Req) -> Result<ResponseStream<Chunk>, TransportError>;
|
||||
}
|
||||
|
||||
pub struct RetryPolicy {
|
||||
pub max_attempts: u64,
|
||||
pub base_delay: Duration,
|
||||
pub retry_on: RetryOn, // e.g., transport errors + 429/5xx
|
||||
}
|
||||
```
|
||||
- `RetryOn` lives in `codex-client` and captures HTTP status classes and transport failures that qualify for retry.
|
||||
- Implementations in `codex-api` plug in their own request types, parsers, and retry policies while reusing the transport’s backoff and error types.
|
||||
- Planned runtime helper:
|
||||
```rust
|
||||
pub async fn run_with_retry<T, F, Fut>(
|
||||
policy: RetryPolicy,
|
||||
make_req: impl Fn() -> Request,
|
||||
op: F,
|
||||
) -> Result<T, TransportError>
|
||||
where
|
||||
F: Fn(Request) -> Fut,
|
||||
Fut: Future<Output = Result<T, TransportError>>,
|
||||
{
|
||||
for attempt in 0..=policy.max_attempts {
|
||||
let req = make_req();
|
||||
match op(req).await {
|
||||
Ok(resp) => return Ok(resp),
|
||||
Err(err) if policy.retry_on.should_retry(&err, attempt) => {
|
||||
tokio::time::sleep(backoff(policy.base_delay, attempt + 1)).await;
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
Err(TransportError::RetryLimit)
|
||||
}
|
||||
```
|
||||
- Unary clients wrap `transport.execute` with this helper and then deserialize.
|
||||
- Stream clients wrap the **initial** `transport.stream` call with this helper. Mid-stream disconnects are surfaced as `StreamError`s; automatic resume/reconnect can be added later on top of this primitive if we introduce cursor support.
|
||||
- Common helpers: `retry::backoff(attempt)`, `errors::{TransportError, StreamError}`.
|
||||
- Streaming utility (SSE framing only):
|
||||
```rust
|
||||
pub fn sse_stream<S>(
|
||||
bytes: S,
|
||||
idle_timeout: Duration,
|
||||
tx: mpsc::Sender<Result<String, StreamError>>,
|
||||
telemetry: Option<Box<dyn Telemetry>>,
|
||||
)
|
||||
where
|
||||
S: Stream<Item = Result<Bytes, TransportError>> + Unpin + Send + 'static;
|
||||
```
|
||||
- `sse_stream` is responsible for timeouts, connection-level errors, and emitting raw `data:` chunks as UTF-8 strings; parsing those strings into structured events is done in `codex-api`.
|
||||
|
||||
- `codex-api` (OpenAI/Codex API library)
|
||||
- Owns typed models for Responses/Chat/Compact plus shared helpers (`Prompt`, tool specs, text controls, `ResponsesApiRequest`, etc.).
|
||||
- Knows about OpenAI/Codex semantics:
|
||||
- URL shapes (`/v1/responses`, `/v1/chat/completions`, `/responses/compact`).
|
||||
- Provider configuration (`WireApi`, base URLs, query params, per-provider retry knobs).
|
||||
- Rate-limit headers (`x-codex-*`) and their mapping into `RateLimitSnapshot` / `CreditsSnapshot`.
|
||||
- Error body formats (`{ error: { type, code, message, plan_type, resets_at } }`) and how they become API errors (context window exceeded, quota/usage limit, etc.).
|
||||
- SSE event names (`response.output_item.done`, `response.completed`, `response.failed`, etc.) and their mapping into high-level events.
|
||||
- Provides a provider abstraction (conceptually similar to `ModelProviderInfo`):
|
||||
```rust
|
||||
pub struct Provider {
|
||||
pub name: String,
|
||||
pub base_url: String,
|
||||
pub wire: WireApi, // Responses | Chat
|
||||
pub headers: HeaderMap,
|
||||
pub retry: RetryConfig,
|
||||
pub stream_idle_timeout: Duration,
|
||||
}
|
||||
|
||||
pub trait AuthProvider {
|
||||
/// Returns a bearer token to use for this request (if any).
|
||||
/// Implementations are expected to be cheap and to surface already-refreshed tokens;
|
||||
/// higher layers (`codex-core`) remain responsible for token refresh flows.
|
||||
fn bearer_token(&self) -> Option<String>;
|
||||
|
||||
/// Optional ChatGPT account id header for Chat mode.
|
||||
fn account_id(&self) -> Option<String>;
|
||||
}
|
||||
```
|
||||
- Ready-made clients built on `HttpTransport`:
|
||||
```rust
|
||||
pub struct ResponsesClient<T: HttpTransport, A: AuthProvider> { /* ... */ }
|
||||
impl<T, A> ResponsesClient<T, A> {
|
||||
pub async fn stream(&self, prompt: &Prompt) -> ApiResult<ResponseStream<ApiEvent>>;
|
||||
pub async fn compact(&self, prompt: &Prompt) -> ApiResult<Vec<ResponseItem>>;
|
||||
}
|
||||
|
||||
pub struct ChatClient<T: HttpTransport, A: AuthProvider> { /* ... */ }
|
||||
impl<T, A> ChatClient<T, A> {
|
||||
pub async fn stream(&self, prompt: &Prompt) -> ApiResult<ResponseStream<ApiEvent>>;
|
||||
}
|
||||
|
||||
pub struct CompactClient<T: HttpTransport, A: AuthProvider> { /* ... */ }
|
||||
impl<T, A> CompactClient<T, A> {
|
||||
pub async fn compact(&self, prompt: &Prompt) -> ApiResult<Vec<ResponseItem>>;
|
||||
}
|
||||
```
|
||||
- Streaming events unified across wire APIs (this can closely mirror `ResponseEvent` today, and we may type-alias one to the other during migration):
|
||||
```rust
|
||||
pub enum ApiEvent {
|
||||
Created,
|
||||
OutputItemAdded(ResponseItem),
|
||||
OutputItemDone(ResponseItem),
|
||||
OutputTextDelta(String),
|
||||
ReasoningContentDelta { delta: String, content_index: i64 },
|
||||
ReasoningSummaryDelta { delta: String, summary_index: i64 },
|
||||
RateLimits(RateLimitSnapshot),
|
||||
Completed { response_id: String, token_usage: Option<TokenUsage> },
|
||||
}
|
||||
```
|
||||
- Error layering:
|
||||
- `codex-client`: defines `TransportError` / `StreamError` (status codes, IO, timeouts).
|
||||
- `codex-api`: defines `ApiError` that wraps `TransportError` plus API-specific errors parsed from bodies and headers.
|
||||
- `codex-core`: maps `ApiError` into existing `CodexErr` variants so downstream callers remain unchanged.
|
||||
- Aggregation strategies (today’s `AggregateStreamExt`) live here as adapters (`Aggregated`, `Streaming`) that transform `ResponseStream<ApiEvent>` into the higher-level views used by `codex-core`.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **Create crates**: add `codex-client` and `codex-api` (names keep the `codex-` prefix). Stub lib files with feature flags/tests wired into the workspace; wire them into `Cargo.toml`.
|
||||
2. **Extract API-level SSE + rate limits into `codex-api`**:
|
||||
- Move the Responses SSE parser (`process_sse`), rate-limit parsing, and related tests from `core/src/client.rs` into `codex-api`, keeping the behavior identical.
|
||||
- Introduce `ApiEvent` (initially equivalent to `ResponseEvent`) and `ApiError`, and adjust the parser to emit those.
|
||||
- Provide test-only helpers for fixture streams (replacement for `CODEX_RS_SSE_FIXTURE`) in `codex-api`.
|
||||
3. **Lift transport layer into `codex-client`**:
|
||||
- Move `CodexHttpClient`/`CodexRequestBuilder`, UA/originator plumbing, and backoff helpers from `core/src/default_client.rs` into `codex-client` (or a thin wrapper on top of it).
|
||||
- Introduce `HttpTransport`, `Request`, `RetryPolicy`, `RetryOn`, and `run_with_retry` as described above.
|
||||
- Keep sandbox/no-proxy toggles behind injected configuration so `codex-client` stays generic and does not depend on Codex-specific env vars.
|
||||
4. **Model provider abstraction in `codex-api`**:
|
||||
- Relocate `ModelProviderInfo` (base URL, env/header resolution, retry knobs, wire API enum) into `codex-api`, expressed in terms of `Provider` and `AuthProvider`.
|
||||
- Ensure provider logic handles:
|
||||
- URL building for Responses/Chat/Compact (including Azure special cases).
|
||||
- Static and env-based headers.
|
||||
- Per-provider retry and idle-timeout settings that map cleanly into `RetryPolicy`/`RetryOn`.
|
||||
5. **API crate wiring**:
|
||||
- Move `Prompt`, tool specs, `ResponsesApiRequest`, `TextControls`, and `ResponseEvent/ResponseStream` into `codex-api` under modules (`common`, `responses`, `chat`, `compact`), keeping public types stable or re-exported through `codex-core` as needed.
|
||||
- Rebuild Responses and Chat clients on top of `HttpTransport` + `StreamClient`, reusing shared retry + SSE helpers; keep aggregation adapters as reusable strategies instead of `ModelClient`-local logic.
|
||||
- Implement Compact on top of `UnaryClient` and the unary `execute` path with JSON deserialization, sharing the same retry policy.
|
||||
- Keep request builders symmetric: each client prepares a `Request<serde_json::Value>`, attaches headers/auth via `AuthProvider`, and plugs in its parser (streaming clients) or deserializer (unary) while sharing retry/backoff configuration derived from `Provider`.
|
||||
6. **Core integration layer**:
|
||||
- Replace `core::ModelClient` internals with thin adapters that construct `codex-api` clients using `Config`, `AuthManager`, and `OtelEventManager`.
|
||||
- Keep the public `ModelClient` API and `ResponseEvent`/`ResponseStream` types stable by re-exporting `codex-api` types or providing type aliases.
|
||||
- Preserve existing auth flows (including ChatGPT token refresh) inside `codex-core` or a thin adapter, using `AuthProvider` to surface bearer tokens to `codex-api` and handling 401/refresh semantics at this layer.
|
||||
7. **Tests/migration**:
|
||||
- Move unit tests for SSE parsing, retry/backoff decisions, and provider/header behavior into the new crates; keep integration tests in `core` using the compatibility layer.
|
||||
- Update fixtures to be consumed via test-only adapters in `codex-api`.
|
||||
- Run targeted `just fmt`, `just fix -p` for the touched crates, and scoped `cargo test -p codex-client`, `-p codex-api`, and existing `codex-core` suites.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
- **UA construction**
|
||||
- `codex-client` exposes an optional UA suffix/provider hook (tiny feature) and remains unaware of the CLI; `codex-core` / the CLI compute the full UA (including `terminal::user_agent()`) and pass the suffix or builder down.
|
||||
- **Config vs provider**
|
||||
- Most configuration stays in `codex-core`. `codex-api::Provider` only contains what is strictly required for HTTP (base URLs, query params, retry/timeout knobs, wire API), while higher-level knobs (reasoning defaults, verbosity flags, etc.) remain core concerns.
|
||||
- **Auth flow ownership**
|
||||
- Auth flows (including ChatGPT token refresh) remain in `codex-core`. `AuthProvider` simply exposes already-fresh tokens/account IDs; 401 handling and refresh retries stay in the existing auth layer.
|
||||
- **Error enums**
|
||||
- `codex-client` continues to define `TransportError` / `StreamError`. `codex-api` defines an `ApiError` (deriving `thiserror::Error`) that wraps `TransportError` and API-specific failures, and `codex-core` maps `ApiError` into existing `CodexErr` variants for callers.
|
||||
- **Streaming reconnection semantics**
|
||||
- For now, mid-stream SSE failures are surfaced as errors and only the initial connection is retried via `run_with_retry`. We will revisit mid-stream reconnect/resume once the underlying APIs support cursor/idempotent event semantics.
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
[package]
|
||||
name = "codex-client"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
name = "codex-client"
|
||||
version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
async-trait = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
eventsource-stream = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
http = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["json", "stream"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt", "time", "sync"] }
|
||||
rand = { workspace = true }
|
||||
eventsource-stream = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -8,6 +8,9 @@ use futures::stream::BoxStream;
|
||||
use http::HeaderMap;
|
||||
use http::Method;
|
||||
use http::StatusCode;
|
||||
use tracing::Level;
|
||||
use tracing::enabled;
|
||||
use tracing::trace;
|
||||
|
||||
pub type ByteStream = BoxStream<'static, Result<Bytes, TransportError>>;
|
||||
|
||||
@@ -83,6 +86,15 @@ impl HttpTransport for ReqwestTransport {
|
||||
}
|
||||
|
||||
async fn stream(&self, req: Request) -> Result<StreamResponse, TransportError> {
|
||||
if enabled!(Level::TRACE) {
|
||||
trace!(
|
||||
"{} to {}: {}",
|
||||
req.method,
|
||||
req.url,
|
||||
req.body.as_ref().unwrap_or_default()
|
||||
);
|
||||
}
|
||||
|
||||
let builder = self.build(req)?;
|
||||
let resp = builder.send().await.map_err(Self::map_error)?;
|
||||
let status = resp.status();
|
||||
|
||||
@@ -52,6 +52,7 @@ regex-lite = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["json", "stream"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
serde_yaml = { workspace = true }
|
||||
sha1 = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
shlex = { workspace = true }
|
||||
@@ -87,6 +88,9 @@ uuid = { workspace = true, features = ["serde", "v4", "v5"] }
|
||||
which = { workspace = true }
|
||||
wildmatch = { workspace = true }
|
||||
|
||||
[features]
|
||||
deterministic_process_ids = []
|
||||
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
landlock = { workspace = true }
|
||||
@@ -115,6 +119,7 @@ keyring = { workspace = true, features = ["sync-secret-service"] }
|
||||
assert_cmd = { workspace = true }
|
||||
assert_matches = { workspace = true }
|
||||
codex-arg0 = { workspace = true }
|
||||
codex-core = { path = ".", features = ["deterministic_process_ids"] }
|
||||
core_test_support = { workspace = true }
|
||||
ctor = { workspace = true }
|
||||
escargot = { workspace = true }
|
||||
|
||||
@@ -33,12 +33,20 @@ pub(crate) fn map_api_error(err: ApiError) -> CodexErr {
|
||||
headers,
|
||||
body,
|
||||
} => {
|
||||
if status == http::StatusCode::INTERNAL_SERVER_ERROR {
|
||||
let body_text = body.unwrap_or_default();
|
||||
|
||||
if status == http::StatusCode::BAD_REQUEST {
|
||||
if body_text
|
||||
.contains("The image data you provided does not represent a valid image")
|
||||
{
|
||||
CodexErr::InvalidImageRequest()
|
||||
} else {
|
||||
CodexErr::InvalidRequest(body_text)
|
||||
}
|
||||
} else if status == http::StatusCode::INTERNAL_SERVER_ERROR {
|
||||
CodexErr::InternalServerError
|
||||
} else if status == http::StatusCode::TOO_MANY_REQUESTS {
|
||||
if let Some(body) = body
|
||||
&& let Ok(err) = serde_json::from_str::<UsageErrorResponse>(&body)
|
||||
{
|
||||
if let Ok(err) = serde_json::from_str::<UsageErrorResponse>(&body_text) {
|
||||
if err.error.error_type.as_deref() == Some("usage_limit_reached") {
|
||||
let rate_limits = headers.as_ref().and_then(parse_rate_limit);
|
||||
let resets_at = err
|
||||
@@ -62,7 +70,7 @@ pub(crate) fn map_api_error(err: ApiError) -> CodexErr {
|
||||
} else {
|
||||
CodexErr::UnexpectedStatus(UnexpectedResponseError {
|
||||
status,
|
||||
body: body.unwrap_or_default(),
|
||||
body: body_text,
|
||||
request_id: extract_request_id(headers.as_ref()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -102,6 +102,9 @@ use crate::protocol::TurnDiffEvent;
|
||||
use crate::protocol::WarningEvent;
|
||||
use crate::rollout::RolloutRecorder;
|
||||
use crate::rollout::RolloutRecorderParams;
|
||||
use crate::rollout::map_session_init_error;
|
||||
use crate::saved_sessions::build_saved_session_entry;
|
||||
use crate::saved_sessions::upsert_saved_session;
|
||||
use crate::shell;
|
||||
use crate::state::ActiveTurn;
|
||||
use crate::state::SessionServices;
|
||||
@@ -136,6 +139,7 @@ use codex_protocol::protocol::InitialHistory;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_utils_readiness::Readiness;
|
||||
use codex_utils_readiness::ReadinessFlag;
|
||||
use std::path::Path;
|
||||
|
||||
/// The high-level interface to the Codex system.
|
||||
/// It operates as a queue pair where you send submissions and receive events.
|
||||
@@ -151,6 +155,7 @@ pub struct Codex {
|
||||
pub struct CodexSpawnOk {
|
||||
pub codex: Codex,
|
||||
pub conversation_id: ConversationId,
|
||||
pub(crate) session: Arc<Session>,
|
||||
}
|
||||
|
||||
pub(crate) const INITIAL_SUBMIT_ID: &str = "";
|
||||
@@ -206,12 +211,13 @@ impl Codex {
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to create session: {e:#}");
|
||||
CodexErr::InternalAgentDied
|
||||
map_session_init_error(&e, &config.codex_home)
|
||||
})?;
|
||||
let conversation_id = session.conversation_id;
|
||||
|
||||
// This task will run until Op::Shutdown is received.
|
||||
tokio::spawn(submission_loop(session, config, rx_sub));
|
||||
let submission_session = Arc::clone(&session);
|
||||
tokio::spawn(submission_loop(submission_session, config, rx_sub));
|
||||
let codex = Codex {
|
||||
next_id: AtomicU64::new(0),
|
||||
tx_sub,
|
||||
@@ -221,6 +227,7 @@ impl Codex {
|
||||
Ok(CodexSpawnOk {
|
||||
codex,
|
||||
conversation_id,
|
||||
session,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -508,7 +515,7 @@ impl Session {
|
||||
|
||||
let rollout_recorder = rollout_recorder.map_err(|e| {
|
||||
error!("failed to initialize rollout recorder: {e:#}");
|
||||
anyhow::anyhow!("failed to initialize rollout recorder: {e:#}")
|
||||
anyhow::Error::from(e)
|
||||
})?;
|
||||
let rollout_path = rollout_recorder.rollout_path.clone();
|
||||
|
||||
@@ -642,18 +649,68 @@ impl Session {
|
||||
}
|
||||
|
||||
/// Ensure all rollout writes are durably flushed.
|
||||
pub(crate) async fn flush_rollout(&self) {
|
||||
pub(crate) async fn flush_rollout(&self) -> std::io::Result<()> {
|
||||
let recorder = {
|
||||
let guard = self.services.rollout.lock().await;
|
||||
guard.clone()
|
||||
};
|
||||
if let Some(rec) = recorder
|
||||
&& let Err(e) = rec.flush().await
|
||||
{
|
||||
warn!("failed to flush rollout recorder: {e}");
|
||||
if let Some(rec) = recorder {
|
||||
rec.flush().await
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn set_session_name(&self, name: Option<String>) -> std::io::Result<()> {
|
||||
let recorder = {
|
||||
let guard = self.services.rollout.lock().await;
|
||||
guard.clone()
|
||||
};
|
||||
if let Some(rec) = recorder {
|
||||
rec.set_session_name(name).await
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn rollout_path(&self) -> CodexResult<PathBuf> {
|
||||
let guard = self.services.rollout.lock().await;
|
||||
let Some(rec) = guard.as_ref() else {
|
||||
return Err(CodexErr::Fatal(
|
||||
"Rollout recorder is not initialized; cannot save session.".to_string(),
|
||||
));
|
||||
};
|
||||
Ok(rec.rollout_path.clone())
|
||||
}
|
||||
|
||||
pub(crate) async fn model(&self) -> String {
|
||||
let state = self.state.lock().await;
|
||||
state.session_configuration.model.clone()
|
||||
}
|
||||
|
||||
pub(crate) async fn save_session(
|
||||
&self,
|
||||
codex_home: &Path,
|
||||
name: &str,
|
||||
) -> CodexResult<crate::SavedSessionEntry> {
|
||||
let trimmed = name.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(CodexErr::Fatal("Usage: /save <name>".to_string()));
|
||||
}
|
||||
let rollout_path = self.rollout_path().await?;
|
||||
self.flush_rollout()
|
||||
.await
|
||||
.map_err(|e| CodexErr::Fatal(format!("failed to flush rollout recorder: {e}")))?;
|
||||
self.set_session_name(Some(trimmed.to_string()))
|
||||
.await
|
||||
.map_err(|e| CodexErr::Fatal(format!("failed to write session name: {e}")))?;
|
||||
let entry =
|
||||
build_saved_session_entry(trimmed.to_string(), rollout_path, self.model().await)
|
||||
.await?;
|
||||
upsert_saved_session(codex_home, entry.clone()).await?;
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
fn next_internal_sub_id(&self) -> String {
|
||||
let id = self
|
||||
.next_internal_sub_id
|
||||
@@ -674,7 +731,9 @@ impl Session {
|
||||
let items = self.build_initial_context(&turn_context);
|
||||
self.record_conversation_items(&turn_context, &items).await;
|
||||
// Ensure initial items are visible to immediate readers (e.g., tests, forks).
|
||||
self.flush_rollout().await;
|
||||
if let Err(e) = self.flush_rollout().await {
|
||||
warn!("failed to flush rollout recorder: {e}");
|
||||
}
|
||||
}
|
||||
InitialHistory::Resumed(_) | InitialHistory::Forked(_) => {
|
||||
let rollout_items = conversation_history.get_rollout_items();
|
||||
@@ -718,10 +777,18 @@ impl Session {
|
||||
|
||||
// If persisting, persist all rollout items as-is (recorder filters)
|
||||
if persist && !rollout_items.is_empty() {
|
||||
self.persist_rollout_items(&rollout_items).await;
|
||||
// Drop legacy SessionMeta lines from the source rollout so the forked
|
||||
// session only contains its own SessionMeta written by the recorder.
|
||||
let filtered =
|
||||
InitialHistory::Forked(rollout_items.clone()).without_session_meta();
|
||||
if !filtered.is_empty() {
|
||||
self.persist_rollout_items(&filtered).await;
|
||||
}
|
||||
}
|
||||
// Flush after seeding history and any persisted rollout copy.
|
||||
self.flush_rollout().await;
|
||||
if let Err(e) = self.flush_rollout().await {
|
||||
warn!("failed to flush rollout recorder: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -975,6 +1042,7 @@ impl Session {
|
||||
.read()
|
||||
.await
|
||||
.resolve_elicitation(server_name, id, response)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Records input items: always append to conversation history and
|
||||
@@ -1034,6 +1102,22 @@ impl Session {
|
||||
state.record_items(items.iter(), turn_context.truncation_policy);
|
||||
}
|
||||
|
||||
pub(crate) async fn record_model_warning(&self, message: impl Into<String>, ctx: &TurnContext) {
|
||||
if !self.enabled(Feature::ModelWarnings).await {
|
||||
return;
|
||||
}
|
||||
|
||||
let item = ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: format!("Warning: {}", message.into()),
|
||||
}],
|
||||
};
|
||||
|
||||
self.record_conversation_items(ctx, &[item]).await;
|
||||
}
|
||||
|
||||
pub(crate) async fn replace_history(&self, items: Vec<ResponseItem>) {
|
||||
let mut state = self.state.lock().await;
|
||||
state.replace_history(items);
|
||||
@@ -1189,22 +1273,17 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a user input item to conversation history and also persist a
|
||||
/// corresponding UserMessage EventMsg to rollout.
|
||||
async fn record_input_and_rollout_usermsg(
|
||||
pub(crate) async fn record_response_item_and_emit_turn_item(
|
||||
&self,
|
||||
turn_context: &TurnContext,
|
||||
response_input: &ResponseInputItem,
|
||||
response_item: ResponseItem,
|
||||
) {
|
||||
let response_item: ResponseItem = response_input.clone().into();
|
||||
// Add to conversation history and persist response item to rollout
|
||||
// Add to conversation history and persist response item to rollout.
|
||||
self.record_conversation_items(turn_context, std::slice::from_ref(&response_item))
|
||||
.await;
|
||||
|
||||
// Derive user message events and persist only UserMessage to rollout
|
||||
let turn_item = parse_turn_item(&response_item);
|
||||
|
||||
if let Some(item @ TurnItem::UserMessage(_)) = turn_item {
|
||||
// Derive a turn item and emit lifecycle events if applicable.
|
||||
if let Some(item) = parse_turn_item(&response_item) {
|
||||
self.emit_turn_item_started(turn_context, &item).await;
|
||||
self.emit_turn_item_completed(turn_context, item).await;
|
||||
}
|
||||
@@ -1465,6 +1544,9 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
|
||||
Op::Review { review_request } => {
|
||||
handlers::review(&sess, &config, sub.id.clone(), review_request).await;
|
||||
}
|
||||
Op::SaveSession { name } => {
|
||||
handlers::save_session(&sess, &config, sub.id.clone(), name).await;
|
||||
}
|
||||
_ => {} // Ignore unknown ops; enum is non_exhaustive to allow extensions.
|
||||
}
|
||||
}
|
||||
@@ -1480,6 +1562,7 @@ mod handlers {
|
||||
use crate::codex::spawn_review_thread;
|
||||
use crate::config::Config;
|
||||
use crate::mcp::auth::compute_auth_statuses;
|
||||
use crate::review_prompts::resolve_review_request;
|
||||
use crate::tasks::CompactTask;
|
||||
use crate::tasks::RegularTask;
|
||||
use crate::tasks::UndoTask;
|
||||
@@ -1493,6 +1576,7 @@ mod handlers {
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use codex_protocol::protocol::ReviewRequest;
|
||||
use codex_protocol::protocol::SaveSessionResponseEvent;
|
||||
use codex_protocol::protocol::TurnAbortReason;
|
||||
|
||||
use codex_protocol::user_input::UserInput;
|
||||
@@ -1743,6 +1827,38 @@ mod handlers {
|
||||
.await;
|
||||
}
|
||||
|
||||
pub async fn save_session(
|
||||
sess: &Arc<Session>,
|
||||
config: &Arc<crate::config::Config>,
|
||||
sub_id: String,
|
||||
name: String,
|
||||
) {
|
||||
match sess.save_session(&config.codex_home, &name).await {
|
||||
Ok(entry) => {
|
||||
let event = Event {
|
||||
id: sub_id,
|
||||
msg: EventMsg::SaveSessionResponse(SaveSessionResponseEvent {
|
||||
name: entry.name,
|
||||
rollout_path: entry.rollout_path,
|
||||
conversation_id: entry.conversation_id,
|
||||
}),
|
||||
};
|
||||
sess.send_event_raw(event).await;
|
||||
}
|
||||
Err(err) => {
|
||||
let message = format!("Failed to save session '{name}': {err}");
|
||||
let event = Event {
|
||||
id: sub_id,
|
||||
msg: EventMsg::Error(ErrorEvent {
|
||||
message,
|
||||
codex_error_info: None,
|
||||
}),
|
||||
};
|
||||
sess.send_event_raw(event).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn shutdown(sess: &Arc<Session>, sub_id: String) -> bool {
|
||||
sess.abort_all_tasks(TurnAbortReason::Interrupted).await;
|
||||
sess.services
|
||||
@@ -1788,14 +1904,28 @@ mod handlers {
|
||||
let turn_context = sess
|
||||
.new_turn_with_sub_id(sub_id.clone(), SessionSettingsUpdate::default())
|
||||
.await;
|
||||
spawn_review_thread(
|
||||
Arc::clone(sess),
|
||||
Arc::clone(config),
|
||||
turn_context.clone(),
|
||||
sub_id,
|
||||
review_request,
|
||||
)
|
||||
.await;
|
||||
match resolve_review_request(review_request, config.cwd.as_path()) {
|
||||
Ok(resolved) => {
|
||||
spawn_review_thread(
|
||||
Arc::clone(sess),
|
||||
Arc::clone(config),
|
||||
turn_context.clone(),
|
||||
sub_id,
|
||||
resolved,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(err) => {
|
||||
let event = Event {
|
||||
id: sub_id,
|
||||
msg: EventMsg::Error(ErrorEvent {
|
||||
message: err.to_string(),
|
||||
codex_error_info: Some(CodexErrorInfo::Other),
|
||||
}),
|
||||
};
|
||||
sess.send_event(&turn_context, event.msg).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1805,7 +1935,7 @@ async fn spawn_review_thread(
|
||||
config: Arc<Config>,
|
||||
parent_turn_context: Arc<TurnContext>,
|
||||
sub_id: String,
|
||||
review_request: ReviewRequest,
|
||||
resolved: crate::review_prompts::ResolvedReviewRequest,
|
||||
) {
|
||||
let model = config.review_model.clone();
|
||||
let review_model_family = find_family_for_model(&model)
|
||||
@@ -1821,7 +1951,7 @@ async fn spawn_review_thread(
|
||||
});
|
||||
|
||||
let base_instructions = REVIEW_PROMPT.to_string();
|
||||
let review_prompt = review_request.prompt.clone();
|
||||
let review_prompt = resolved.prompt.clone();
|
||||
let provider = parent_turn_context.client.get_provider();
|
||||
let auth_manager = parent_turn_context.client.get_auth_manager();
|
||||
let model_family = review_model_family.clone();
|
||||
@@ -1880,14 +2010,13 @@ async fn spawn_review_thread(
|
||||
text: review_prompt,
|
||||
}];
|
||||
let tc = Arc::new(review_turn_context);
|
||||
sess.spawn_task(
|
||||
tc.clone(),
|
||||
input,
|
||||
ReviewTask::new(review_request.append_to_original_thread),
|
||||
)
|
||||
.await;
|
||||
sess.spawn_task(tc.clone(), input, ReviewTask::new()).await;
|
||||
|
||||
// Announce entering review mode so UIs can switch modes.
|
||||
let review_request = ReviewRequest {
|
||||
target: resolved.target,
|
||||
user_facing_hint: Some(resolved.user_facing_hint),
|
||||
};
|
||||
sess.send_event(&tc, EventMsg::EnteredReviewMode(review_request))
|
||||
.await;
|
||||
}
|
||||
@@ -1921,7 +2050,8 @@ pub(crate) async fn run_task(
|
||||
sess.send_event(&turn_context, event).await;
|
||||
|
||||
let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input);
|
||||
sess.record_input_and_rollout_usermsg(turn_context.as_ref(), &initial_input_for_turn)
|
||||
let response_item: ResponseItem = initial_input_for_turn.clone().into();
|
||||
sess.record_response_item_and_emit_turn_item(turn_context.as_ref(), response_item)
|
||||
.await;
|
||||
|
||||
sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token())
|
||||
@@ -2011,6 +2141,13 @@ pub(crate) async fn run_task(
|
||||
// Aborted turn is reported via a different event.
|
||||
break;
|
||||
}
|
||||
Err(CodexErr::InvalidImageRequest()) => {
|
||||
let mut state = sess.state.lock().await;
|
||||
error_or_panic(
|
||||
"Invalid image detected, replacing it in the last turn to prevent poisoning",
|
||||
);
|
||||
state.history.replace_last_turn_images("Invalid image");
|
||||
}
|
||||
Err(e) => {
|
||||
info!("Turn error: {e:#}");
|
||||
let event = EventMsg::Error(e.to_error_event(None));
|
||||
@@ -2118,6 +2255,8 @@ async fn run_turn(
|
||||
}
|
||||
Err(CodexErr::UsageNotIncluded) => return Err(CodexErr::UsageNotIncluded),
|
||||
Err(e @ CodexErr::QuotaExceeded) => return Err(e),
|
||||
Err(e @ CodexErr::InvalidImageRequest()) => return Err(e),
|
||||
Err(e @ CodexErr::InvalidRequest(_)) => return Err(e),
|
||||
Err(e @ CodexErr::RefreshTokenFailed(_)) => return Err(e),
|
||||
Err(e) => {
|
||||
// Use the configured provider-specific stream retry budget.
|
||||
@@ -2453,7 +2592,10 @@ mod tests {
|
||||
use crate::tools::format_exec_output_str;
|
||||
|
||||
use crate::protocol::CompactedItem;
|
||||
use crate::protocol::CreditsSnapshot;
|
||||
use crate::protocol::InitialHistory;
|
||||
use crate::protocol::RateLimitSnapshot;
|
||||
use crate::protocol::RateLimitWindow;
|
||||
use crate::protocol::ResumedHistory;
|
||||
use crate::state::TaskKind;
|
||||
use crate::tasks::SessionTask;
|
||||
@@ -2523,6 +2665,75 @@ mod tests {
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_rate_limits_retains_previous_credits() {
|
||||
let codex_home = tempfile::tempdir().expect("create temp dir");
|
||||
let config = Config::load_from_base_config_with_overrides(
|
||||
ConfigToml::default(),
|
||||
ConfigOverrides::default(),
|
||||
codex_home.path().to_path_buf(),
|
||||
)
|
||||
.expect("load default test config");
|
||||
let config = Arc::new(config);
|
||||
let session_configuration = SessionConfiguration {
|
||||
provider: config.model_provider.clone(),
|
||||
model: config.model.clone(),
|
||||
model_reasoning_effort: config.model_reasoning_effort,
|
||||
model_reasoning_summary: config.model_reasoning_summary,
|
||||
developer_instructions: config.developer_instructions.clone(),
|
||||
user_instructions: config.user_instructions.clone(),
|
||||
base_instructions: config.base_instructions.clone(),
|
||||
compact_prompt: config.compact_prompt.clone(),
|
||||
approval_policy: config.approval_policy,
|
||||
sandbox_policy: config.sandbox_policy.clone(),
|
||||
cwd: config.cwd.clone(),
|
||||
original_config_do_not_use: Arc::clone(&config),
|
||||
features: Features::default(),
|
||||
exec_policy: Arc::new(ExecPolicy::empty()),
|
||||
session_source: SessionSource::Exec,
|
||||
};
|
||||
|
||||
let mut state = SessionState::new(session_configuration);
|
||||
let initial = RateLimitSnapshot {
|
||||
primary: Some(RateLimitWindow {
|
||||
used_percent: 10.0,
|
||||
window_minutes: Some(15),
|
||||
resets_at: Some(1_700),
|
||||
}),
|
||||
secondary: None,
|
||||
credits: Some(CreditsSnapshot {
|
||||
has_credits: true,
|
||||
unlimited: false,
|
||||
balance: Some("10.00".to_string()),
|
||||
}),
|
||||
};
|
||||
state.set_rate_limits(initial.clone());
|
||||
|
||||
let update = RateLimitSnapshot {
|
||||
primary: Some(RateLimitWindow {
|
||||
used_percent: 40.0,
|
||||
window_minutes: Some(30),
|
||||
resets_at: Some(1_800),
|
||||
}),
|
||||
secondary: Some(RateLimitWindow {
|
||||
used_percent: 5.0,
|
||||
window_minutes: Some(60),
|
||||
resets_at: Some(1_900),
|
||||
}),
|
||||
credits: None,
|
||||
};
|
||||
state.set_rate_limits(update.clone());
|
||||
|
||||
assert_eq!(
|
||||
state.latest_rate_limits,
|
||||
Some(RateLimitSnapshot {
|
||||
primary: update.primary.clone(),
|
||||
secondary: update.secondary,
|
||||
credits: initial.credits,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefers_structured_content_when_present() {
|
||||
let ctr = CallToolResult {
|
||||
@@ -2795,6 +3006,40 @@ mod tests {
|
||||
(session, turn_context, rx_event)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn record_model_warning_appends_user_message() {
|
||||
let (session, turn_context) = make_session_and_context();
|
||||
|
||||
session
|
||||
.state
|
||||
.lock()
|
||||
.await
|
||||
.session_configuration
|
||||
.features
|
||||
.enable(Feature::ModelWarnings);
|
||||
|
||||
session
|
||||
.record_model_warning("too many unified exec sessions", &turn_context)
|
||||
.await;
|
||||
|
||||
let mut history = session.clone_history().await;
|
||||
let history_items = history.get_history();
|
||||
let last = history_items.last().expect("warning recorded");
|
||||
|
||||
match last {
|
||||
ResponseItem::Message { role, content, .. } => {
|
||||
assert_eq!(role, "user");
|
||||
assert_eq!(
|
||||
content,
|
||||
&vec![ContentItem::InputText {
|
||||
text: "Warning: too many unified exec sessions".to_string(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
other => panic!("expected user message, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct NeverEndingTask {
|
||||
kind: TaskKind,
|
||||
@@ -2886,7 +3131,7 @@ mod tests {
|
||||
let input = vec![UserInput::Text {
|
||||
text: "start review".to_string(),
|
||||
}];
|
||||
sess.spawn_task(Arc::clone(&tc), input, ReviewTask::new(true))
|
||||
sess.spawn_task(Arc::clone(&tc), input, ReviewTask::new())
|
||||
.await;
|
||||
|
||||
sess.abort_all_tasks(TurnAbortReason::Interrupted).await;
|
||||
@@ -2914,6 +3159,8 @@ mod tests {
|
||||
.expect("event");
|
||||
match evt.msg {
|
||||
EventMsg::RawResponseItem(_) => continue,
|
||||
EventMsg::ItemStarted(_) | EventMsg::ItemCompleted(_) => continue,
|
||||
EventMsg::AgentMessage(_) => continue,
|
||||
EventMsg::TurnAborted(e) => {
|
||||
assert_eq!(TurnAbortReason::Interrupted, e.reason);
|
||||
break;
|
||||
@@ -2923,23 +3170,7 @@ mod tests {
|
||||
}
|
||||
|
||||
let history = sess.clone_history().await.get_history();
|
||||
let found = history.iter().any(|item| match item {
|
||||
ResponseItem::Message { role, content, .. } if role == "user" => {
|
||||
content.iter().any(|ci| match ci {
|
||||
ContentItem::InputText { text } => {
|
||||
text.contains("<user_action>")
|
||||
&& text.contains("review")
|
||||
&& text.contains("interrupted")
|
||||
}
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
_ => false,
|
||||
});
|
||||
assert!(
|
||||
found,
|
||||
"synthetic review interruption not recorded in history"
|
||||
);
|
||||
let _ = history;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
use crate::codex::Codex;
|
||||
use crate::codex::Session;
|
||||
use crate::error::Result as CodexResult;
|
||||
use crate::protocol::Event;
|
||||
use crate::protocol::Op;
|
||||
use crate::protocol::Submission;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct CodexConversation {
|
||||
codex: Codex,
|
||||
rollout_path: PathBuf,
|
||||
session: Arc<Session>,
|
||||
}
|
||||
|
||||
/// Conduit for the bidirectional stream of messages that compose a conversation
|
||||
/// in Codex.
|
||||
impl CodexConversation {
|
||||
pub(crate) fn new(codex: Codex, rollout_path: PathBuf) -> Self {
|
||||
pub(crate) fn new(codex: Codex, rollout_path: PathBuf, session: Arc<Session>) -> Self {
|
||||
Self {
|
||||
codex,
|
||||
rollout_path,
|
||||
session,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,4 +40,24 @@ impl CodexConversation {
|
||||
pub fn rollout_path(&self) -> PathBuf {
|
||||
self.rollout_path.clone()
|
||||
}
|
||||
|
||||
pub async fn flush_rollout(&self) -> CodexResult<()> {
|
||||
Ok(self.session.flush_rollout().await?)
|
||||
}
|
||||
|
||||
pub async fn set_session_name(&self, name: Option<String>) -> CodexResult<()> {
|
||||
Ok(self.session.set_session_name(name).await?)
|
||||
}
|
||||
|
||||
pub async fn model(&self) -> String {
|
||||
self.session.model().await
|
||||
}
|
||||
|
||||
pub async fn save_session(
|
||||
&self,
|
||||
codex_home: &std::path::Path,
|
||||
name: &str,
|
||||
) -> CodexResult<crate::SavedSessionEntry> {
|
||||
self.session.save_session(codex_home, name).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ use crate::project_doc::DEFAULT_PROJECT_DOC_FILENAME;
|
||||
use crate::project_doc::LOCAL_PROJECT_DOC_FILENAME;
|
||||
use crate::protocol::AskForApproval;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::util::resolve_path;
|
||||
use codex_app_server_protocol::Tools;
|
||||
use codex_app_server_protocol::UserSavedConfig;
|
||||
use codex_protocol::config_types::ForcedLoginMethod;
|
||||
@@ -160,6 +161,9 @@ pub struct Config {
|
||||
/// Enable ASCII animations and shimmer effects in the TUI.
|
||||
pub animations: bool,
|
||||
|
||||
/// Show startup tooltips in the TUI welcome screen.
|
||||
pub show_tooltips: bool,
|
||||
|
||||
/// The directory that should be treated as the current working directory
|
||||
/// for the session. All relative paths inside the business-logic layer are
|
||||
/// resolved against this path.
|
||||
@@ -1016,15 +1020,8 @@ impl Config {
|
||||
let additional_writable_roots: Vec<PathBuf> = additional_writable_roots
|
||||
.into_iter()
|
||||
.map(|path| {
|
||||
let absolute = if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
resolved_cwd.join(path)
|
||||
};
|
||||
match canonicalize(&absolute) {
|
||||
Ok(canonical) => canonical,
|
||||
Err(_) => absolute,
|
||||
}
|
||||
let absolute = resolve_path(&resolved_cwd, &path);
|
||||
canonicalize(&absolute).unwrap_or(absolute)
|
||||
})
|
||||
.collect();
|
||||
let active_project = cfg
|
||||
@@ -1258,6 +1255,7 @@ impl Config {
|
||||
.map(|t| t.notifications.clone())
|
||||
.unwrap_or_default(),
|
||||
animations: cfg.tui.as_ref().map(|t| t.animations).unwrap_or(true),
|
||||
show_tooltips: cfg.tui.as_ref().map(|t| t.show_tooltips).unwrap_or(true),
|
||||
otel: {
|
||||
let t: OtelConfigToml = cfg.otel.unwrap_or_default();
|
||||
let log_user_prompt = t.log_user_prompt.unwrap_or(false);
|
||||
@@ -1299,11 +1297,7 @@ impl Config {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let full_path = if p.is_relative() {
|
||||
cwd.join(p)
|
||||
} else {
|
||||
p.to_path_buf()
|
||||
};
|
||||
let full_path = resolve_path(cwd, p);
|
||||
|
||||
let contents = std::fs::read_to_string(&full_path).map_err(|e| {
|
||||
std::io::Error::new(
|
||||
@@ -1436,6 +1430,7 @@ persistence = "none"
|
||||
let tui = parsed.tui.expect("config should include tui section");
|
||||
|
||||
assert_eq!(tui.notifications, Notifications::Enabled(true));
|
||||
assert!(tui.show_tooltips);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3009,6 +3004,7 @@ model_verbosity = "high"
|
||||
disable_paste_burst: false,
|
||||
tui_notifications: Default::default(),
|
||||
animations: true,
|
||||
show_tooltips: true,
|
||||
otel: OtelConfig::default(),
|
||||
},
|
||||
o3_profile_config
|
||||
@@ -3082,6 +3078,7 @@ model_verbosity = "high"
|
||||
disable_paste_burst: false,
|
||||
tui_notifications: Default::default(),
|
||||
animations: true,
|
||||
show_tooltips: true,
|
||||
otel: OtelConfig::default(),
|
||||
};
|
||||
|
||||
@@ -3170,6 +3167,7 @@ model_verbosity = "high"
|
||||
disable_paste_burst: false,
|
||||
tui_notifications: Default::default(),
|
||||
animations: true,
|
||||
show_tooltips: true,
|
||||
otel: OtelConfig::default(),
|
||||
};
|
||||
|
||||
@@ -3244,6 +3242,7 @@ model_verbosity = "high"
|
||||
disable_paste_burst: false,
|
||||
tui_notifications: Default::default(),
|
||||
animations: true,
|
||||
show_tooltips: true,
|
||||
otel: OtelConfig::default(),
|
||||
};
|
||||
|
||||
|
||||
@@ -256,8 +256,8 @@ pub struct History {
|
||||
/// If true, history entries will not be written to disk.
|
||||
pub persistence: HistoryPersistence,
|
||||
|
||||
/// If set, the maximum size of the history file in bytes.
|
||||
/// TODO(mbolin): Not currently honored.
|
||||
/// If set, the maximum size of the history file in bytes. The oldest entries
|
||||
/// are dropped once the file exceeds this limit.
|
||||
pub max_bytes: Option<usize>,
|
||||
}
|
||||
|
||||
@@ -368,6 +368,11 @@ pub struct Tui {
|
||||
/// Defaults to `true`.
|
||||
#[serde(default = "default_true")]
|
||||
pub animations: bool,
|
||||
|
||||
/// Show startup tooltips in the TUI welcome screen.
|
||||
/// Defaults to `true`.
|
||||
#[serde(default = "default_true")]
|
||||
pub show_tooltips: bool,
|
||||
}
|
||||
|
||||
const fn default_true() -> bool {
|
||||
|
||||
@@ -5,6 +5,8 @@ use crate::truncate::approx_token_count;
|
||||
use crate::truncate::approx_tokens_from_byte_count;
|
||||
use crate::truncate::truncate_function_output_items_with_policy;
|
||||
use crate::truncate::truncate_text;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::TokenUsage;
|
||||
@@ -118,6 +120,37 @@ impl ContextManager {
|
||||
self.items = items;
|
||||
}
|
||||
|
||||
pub(crate) fn replace_last_turn_images(&mut self, placeholder: &str) {
|
||||
let Some(last_item) = self.items.last_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
match last_item {
|
||||
ResponseItem::Message { role, content, .. } if role == "user" => {
|
||||
for item in content.iter_mut() {
|
||||
if matches!(item, ContentItem::InputImage { .. }) {
|
||||
*item = ContentItem::InputText {
|
||||
text: placeholder.to_string(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
ResponseItem::FunctionCallOutput { output, .. } => {
|
||||
let Some(content_items) = output.content_items.as_mut() else {
|
||||
return;
|
||||
};
|
||||
for item in content_items.iter_mut() {
|
||||
if matches!(item, FunctionCallOutputContentItem::InputImage { .. }) {
|
||||
*item = FunctionCallOutputContentItem::InputText {
|
||||
text: placeholder.to_string(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn update_token_info(
|
||||
&mut self,
|
||||
usage: &TokenUsage,
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::CodexAuth;
|
||||
use crate::codex::Codex;
|
||||
use crate::codex::CodexSpawnOk;
|
||||
use crate::codex::INITIAL_SUBMIT_ID;
|
||||
use crate::codex::Session;
|
||||
use crate::codex_conversation::CodexConversation;
|
||||
use crate::config::Config;
|
||||
use crate::error::CodexErr;
|
||||
@@ -11,6 +12,7 @@ use crate::protocol::Event;
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::protocol::SessionConfiguredEvent;
|
||||
use crate::rollout::RolloutRecorder;
|
||||
use crate::saved_sessions::resolve_rollout_path;
|
||||
use codex_protocol::ConversationId;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
@@ -18,6 +20,7 @@ use codex_protocol::protocol::InitialHistory;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
@@ -60,6 +63,7 @@ impl ConversationManager {
|
||||
self.session_source.clone()
|
||||
}
|
||||
|
||||
/// Start a brand new conversation with default initial history.
|
||||
pub async fn new_conversation(&self, config: Config) -> CodexResult<NewConversation> {
|
||||
self.spawn_conversation(config, self.auth_manager.clone())
|
||||
.await
|
||||
@@ -73,6 +77,7 @@ impl ConversationManager {
|
||||
let CodexSpawnOk {
|
||||
codex,
|
||||
conversation_id,
|
||||
session,
|
||||
} = Codex::spawn(
|
||||
config,
|
||||
auth_manager,
|
||||
@@ -80,13 +85,14 @@ impl ConversationManager {
|
||||
self.session_source.clone(),
|
||||
)
|
||||
.await?;
|
||||
self.finalize_spawn(codex, conversation_id).await
|
||||
self.finalize_spawn(codex, conversation_id, session).await
|
||||
}
|
||||
|
||||
async fn finalize_spawn(
|
||||
&self,
|
||||
codex: Codex,
|
||||
conversation_id: ConversationId,
|
||||
session: Arc<Session>,
|
||||
) -> CodexResult<NewConversation> {
|
||||
// The first event must be `SessionInitialized`. Validate and forward it
|
||||
// to the caller so that they can display it in the conversation
|
||||
@@ -105,6 +111,7 @@ impl ConversationManager {
|
||||
let conversation = Arc::new(CodexConversation::new(
|
||||
codex,
|
||||
session_configured.rollout_path.clone(),
|
||||
session,
|
||||
));
|
||||
self.conversations
|
||||
.write()
|
||||
@@ -129,6 +136,7 @@ impl ConversationManager {
|
||||
.ok_or_else(|| CodexErr::ConversationNotFound(conversation_id))
|
||||
}
|
||||
|
||||
/// Resume a conversation from an on-disk rollout file.
|
||||
pub async fn resume_conversation_from_rollout(
|
||||
&self,
|
||||
config: Config,
|
||||
@@ -140,6 +148,23 @@ impl ConversationManager {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Resume a conversation by saved-session name or rollout id string.
|
||||
pub async fn resume_conversation_from_identifier(
|
||||
&self,
|
||||
config: Config,
|
||||
identifier: &str,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
) -> CodexResult<NewConversation> {
|
||||
let Some(path) = resolve_rollout_path(&config.codex_home, identifier).await? else {
|
||||
return Err(CodexErr::Fatal(format!(
|
||||
"No saved session or rollout found for '{identifier}'"
|
||||
)));
|
||||
};
|
||||
self.resume_conversation_from_rollout(config, path, auth_manager)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Resume a conversation from provided rollout history items.
|
||||
pub async fn resume_conversation_with_history(
|
||||
&self,
|
||||
config: Config,
|
||||
@@ -149,6 +174,7 @@ impl ConversationManager {
|
||||
let CodexSpawnOk {
|
||||
codex,
|
||||
conversation_id,
|
||||
session,
|
||||
} = Codex::spawn(
|
||||
config,
|
||||
auth_manager,
|
||||
@@ -156,7 +182,54 @@ impl ConversationManager {
|
||||
self.session_source.clone(),
|
||||
)
|
||||
.await?;
|
||||
self.finalize_spawn(codex, conversation_id).await
|
||||
self.finalize_spawn(codex, conversation_id, session).await
|
||||
}
|
||||
|
||||
/// Fork a new conversation from the given rollout path.
|
||||
pub async fn fork_from_rollout(
|
||||
&self,
|
||||
config: Config,
|
||||
path: PathBuf,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
) -> CodexResult<NewConversation> {
|
||||
let initial_history = RolloutRecorder::get_rollout_history(&path).await?;
|
||||
let forked = match initial_history {
|
||||
InitialHistory::Resumed(resumed) => InitialHistory::Forked(resumed.history),
|
||||
InitialHistory::Forked(items) => InitialHistory::Forked(items),
|
||||
InitialHistory::New => InitialHistory::New,
|
||||
};
|
||||
self.resume_conversation_with_history(config, forked, auth_manager)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Fork a new conversation from a saved-session name or rollout id string.
|
||||
pub async fn fork_from_identifier(
|
||||
&self,
|
||||
config: Config,
|
||||
identifier: &str,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
) -> CodexResult<NewConversation> {
|
||||
let Some(path) = resolve_rollout_path(&config.codex_home, identifier).await? else {
|
||||
return Err(CodexErr::Fatal(format!(
|
||||
"No saved session or rollout found for '{identifier}'"
|
||||
)));
|
||||
};
|
||||
self.fork_from_rollout(config, path, auth_manager).await
|
||||
}
|
||||
|
||||
/// Persist a human-friendly session name and record it in saved_sessions.json.
|
||||
pub async fn save_session(
|
||||
&self,
|
||||
conversation_id: ConversationId,
|
||||
codex_home: &Path,
|
||||
name: &str,
|
||||
) -> CodexResult<crate::SavedSessionEntry> {
|
||||
let trimmed = name.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(CodexErr::Fatal("Usage: /save <name>".to_string()));
|
||||
}
|
||||
let conversation = self.get_conversation(conversation_id).await?;
|
||||
conversation.save_session(codex_home, trimmed).await
|
||||
}
|
||||
|
||||
/// Removes the conversation from the manager's internal map, though the
|
||||
@@ -189,9 +262,10 @@ impl ConversationManager {
|
||||
let CodexSpawnOk {
|
||||
codex,
|
||||
conversation_id,
|
||||
session,
|
||||
} = Codex::spawn(config, auth_manager, history, self.session_source.clone()).await?;
|
||||
|
||||
self.finalize_spawn(codex, conversation_id).await
|
||||
self.finalize_spawn(codex, conversation_id, session).await
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -103,6 +103,14 @@ pub enum CodexErr {
|
||||
#[error("{0}")]
|
||||
UnexpectedStatus(UnexpectedResponseError),
|
||||
|
||||
/// Invalid request.
|
||||
#[error("{0}")]
|
||||
InvalidRequest(String),
|
||||
|
||||
/// Invalid image.
|
||||
#[error("Image poisoning")]
|
||||
InvalidImageRequest(),
|
||||
|
||||
#[error("{0}")]
|
||||
UsageLimitReached(UsageLimitReachedError),
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ fn evaluate_with_policy(
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn create_approval_requirement_for_command(
|
||||
pub(crate) async fn create_approval_requirement_for_command(
|
||||
policy: &Policy,
|
||||
command: &[String],
|
||||
approval_policy: AskForApproval,
|
||||
@@ -296,8 +296,8 @@ prefix_rule(pattern=["rm"], decision="forbidden")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_requirement_prefers_execpolicy_match() {
|
||||
#[tokio::test]
|
||||
async fn approval_requirement_prefers_execpolicy_match() {
|
||||
let policy_src = r#"prefix_rule(pattern=["rm"], decision="prompt")"#;
|
||||
let mut parser = PolicyParser::new();
|
||||
parser
|
||||
@@ -312,7 +312,8 @@ prefix_rule(pattern=["rm"], decision="forbidden")
|
||||
AskForApproval::OnRequest,
|
||||
&SandboxPolicy::DangerFullAccess,
|
||||
SandboxPermissions::UseDefault,
|
||||
);
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
requirement,
|
||||
@@ -322,8 +323,8 @@ prefix_rule(pattern=["rm"], decision="forbidden")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_requirement_respects_approval_policy() {
|
||||
#[tokio::test]
|
||||
async fn approval_requirement_respects_approval_policy() {
|
||||
let policy_src = r#"prefix_rule(pattern=["rm"], decision="prompt")"#;
|
||||
let mut parser = PolicyParser::new();
|
||||
parser
|
||||
@@ -338,7 +339,8 @@ prefix_rule(pattern=["rm"], decision="forbidden")
|
||||
AskForApproval::Never,
|
||||
&SandboxPolicy::DangerFullAccess,
|
||||
SandboxPermissions::UseDefault,
|
||||
);
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
requirement,
|
||||
@@ -348,8 +350,8 @@ prefix_rule(pattern=["rm"], decision="forbidden")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_requirement_falls_back_to_heuristics() {
|
||||
#[tokio::test]
|
||||
async fn approval_requirement_falls_back_to_heuristics() {
|
||||
let command = vec!["python".to_string()];
|
||||
|
||||
let empty_policy = Policy::empty();
|
||||
@@ -359,7 +361,8 @@ prefix_rule(pattern=["rm"], decision="forbidden")
|
||||
AskForApproval::UnlessTrusted,
|
||||
&SandboxPolicy::ReadOnly,
|
||||
SandboxPermissions::UseDefault,
|
||||
);
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
requirement,
|
||||
|
||||
@@ -51,6 +51,10 @@ pub enum Feature {
|
||||
ShellTool,
|
||||
/// Allow model to call multiple tools in parallel (only for models supporting it).
|
||||
ParallelToolCalls,
|
||||
/// Experimental skills injection (CLI flag-driven).
|
||||
Skills,
|
||||
/// Send warnings to the model to correct it on the tool usage.
|
||||
ModelWarnings,
|
||||
}
|
||||
|
||||
impl Feature {
|
||||
@@ -265,6 +269,12 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
stage: Stage::Stable,
|
||||
default_enabled: true,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::ShellTool,
|
||||
key: "shell_tool",
|
||||
stage: Stage::Stable,
|
||||
default_enabled: true,
|
||||
},
|
||||
// Unstable features.
|
||||
FeatureSpec {
|
||||
id: Feature::UnifiedExec,
|
||||
@@ -321,9 +331,15 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::ShellTool,
|
||||
key: "shell_tool",
|
||||
stage: Stage::Stable,
|
||||
default_enabled: true,
|
||||
id: Feature::ModelWarnings,
|
||||
key: "warnings",
|
||||
stage: Stage::Experimental,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::Skills,
|
||||
key: "skills",
|
||||
stage: Stage::Experimental,
|
||||
default_enabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2,6 +2,7 @@ use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::util::resolve_path;
|
||||
use codex_app_server_protocol::GitSha;
|
||||
use codex_protocol::protocol::GitInfo;
|
||||
use futures::future::join_all;
|
||||
@@ -131,11 +132,15 @@ pub async fn recent_commits(cwd: &Path, limit: usize) -> Vec<CommitLogEntry> {
|
||||
}
|
||||
|
||||
let fmt = "%H%x1f%ct%x1f%s"; // <sha> <US> <commit_time> <US> <subject>
|
||||
let n = limit.max(1).to_string();
|
||||
let Some(log_out) =
|
||||
run_git_command_with_timeout(&["log", "-n", &n, &format!("--pretty=format:{fmt}")], cwd)
|
||||
.await
|
||||
else {
|
||||
let limit_arg = (limit > 0).then(|| limit.to_string());
|
||||
let mut args: Vec<String> = vec!["log".to_string()];
|
||||
if let Some(n) = &limit_arg {
|
||||
args.push("-n".to_string());
|
||||
args.push(n.clone());
|
||||
}
|
||||
args.push(format!("--pretty=format:{fmt}"));
|
||||
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
|
||||
let Some(log_out) = run_git_command_with_timeout(&arg_refs, cwd).await else {
|
||||
return Vec::new();
|
||||
};
|
||||
if !log_out.status.success() {
|
||||
@@ -544,11 +549,7 @@ pub fn resolve_root_git_project_for_trust(cwd: &Path) -> Option<PathBuf> {
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
let git_dir_path_raw = if Path::new(&git_dir_s).is_absolute() {
|
||||
PathBuf::from(&git_dir_s)
|
||||
} else {
|
||||
base.join(&git_dir_s)
|
||||
};
|
||||
let git_dir_path_raw = resolve_path(base, &PathBuf::from(&git_dir_s));
|
||||
|
||||
// Normalize to handle macOS /var vs /private/var and resolve ".." segments.
|
||||
let git_dir_path = std::fs::canonicalize(&git_dir_path_raw).unwrap_or(git_dir_path_raw);
|
||||
|
||||
@@ -58,6 +58,7 @@ pub use model_provider_info::create_oss_provider_with_base_url;
|
||||
mod conversation_manager;
|
||||
mod event_mapping;
|
||||
pub mod review_format;
|
||||
pub mod review_prompts;
|
||||
pub use codex_protocol::protocol::InitialHistory;
|
||||
pub use conversation_manager::ConversationManager;
|
||||
pub use conversation_manager::NewConversation;
|
||||
@@ -70,8 +71,10 @@ mod openai_model_info;
|
||||
pub mod project_doc;
|
||||
mod rollout;
|
||||
pub(crate) mod safety;
|
||||
pub mod saved_sessions;
|
||||
pub mod seatbelt;
|
||||
pub mod shell;
|
||||
pub mod skills;
|
||||
pub mod spawn;
|
||||
pub mod terminal;
|
||||
mod tools;
|
||||
@@ -87,6 +90,12 @@ pub use rollout::list::ConversationsPage;
|
||||
pub use rollout::list::Cursor;
|
||||
pub use rollout::list::parse_cursor;
|
||||
pub use rollout::list::read_head_for_summary;
|
||||
pub use saved_sessions::SavedSessionEntry;
|
||||
pub use saved_sessions::build_saved_session_entry;
|
||||
pub use saved_sessions::list_saved_sessions;
|
||||
pub use saved_sessions::resolve_rollout_path;
|
||||
pub use saved_sessions::resolve_saved_session;
|
||||
pub use saved_sessions::upsert_saved_session;
|
||||
mod function_tool;
|
||||
mod state;
|
||||
mod tasks;
|
||||
|
||||
@@ -12,7 +12,6 @@ use std::env;
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::mcp::auth::McpAuthStatusEntry;
|
||||
@@ -55,6 +54,7 @@ use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use sha1::Digest;
|
||||
use sha1::Sha1;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio::task::JoinSet;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
@@ -128,7 +128,7 @@ struct ElicitationRequestManager {
|
||||
}
|
||||
|
||||
impl ElicitationRequestManager {
|
||||
fn resolve(
|
||||
async fn resolve(
|
||||
&self,
|
||||
server_name: String,
|
||||
id: RequestId,
|
||||
@@ -136,7 +136,7 @@ impl ElicitationRequestManager {
|
||||
) -> Result<()> {
|
||||
self.requests
|
||||
.lock()
|
||||
.map_err(|e| anyhow!("failed to lock elicitation requests: {e:?}"))?
|
||||
.await
|
||||
.remove(&(server_name, id))
|
||||
.ok_or_else(|| anyhow!("elicitation request not found"))?
|
||||
.send(response)
|
||||
@@ -151,7 +151,8 @@ impl ElicitationRequestManager {
|
||||
let server_name = server_name.clone();
|
||||
async move {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
if let Ok(mut lock) = elicitation_requests.lock() {
|
||||
{
|
||||
let mut lock = elicitation_requests.lock().await;
|
||||
lock.insert((server_name.clone(), id.clone()), tx);
|
||||
}
|
||||
let _ = tx_event
|
||||
@@ -365,13 +366,15 @@ impl McpConnectionManager {
|
||||
.context("failed to get client")
|
||||
}
|
||||
|
||||
pub fn resolve_elicitation(
|
||||
pub async fn resolve_elicitation(
|
||||
&self,
|
||||
server_name: String,
|
||||
id: RequestId,
|
||||
response: ElicitationResponse,
|
||||
) -> Result<()> {
|
||||
self.elicitation_requests.resolve(server_name, id, response)
|
||||
self.elicitation_requests
|
||||
.resolve(server_name, id, response)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Returns a single map that contains all tools. Each key is the
|
||||
|
||||
@@ -16,8 +16,14 @@
|
||||
|
||||
use std::fs::File;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::BufRead;
|
||||
use std::io::BufReader;
|
||||
use std::io::Read;
|
||||
use std::io::Result;
|
||||
use std::io::Seek;
|
||||
use std::io::SeekFrom;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::Deserialize;
|
||||
@@ -39,10 +45,13 @@ use std::os::unix::fs::PermissionsExt;
|
||||
/// Filename that stores the message history inside `~/.codex`.
|
||||
const HISTORY_FILENAME: &str = "history.jsonl";
|
||||
|
||||
/// When history exceeds the hard cap, trim it down to this fraction of `max_bytes`.
|
||||
const HISTORY_SOFT_CAP_RATIO: f64 = 0.8;
|
||||
|
||||
const MAX_RETRIES: usize = 10;
|
||||
const RETRY_SLEEP: Duration = Duration::from_millis(100);
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct HistoryEntry {
|
||||
pub session_id: String,
|
||||
pub ts: u64,
|
||||
@@ -97,11 +106,12 @@ pub(crate) async fn append_entry(
|
||||
.map_err(|e| std::io::Error::other(format!("failed to serialise history entry: {e}")))?;
|
||||
line.push('\n');
|
||||
|
||||
// Open in append-only mode.
|
||||
// Open the history file for read/write access (append-only on Unix).
|
||||
let mut options = OpenOptions::new();
|
||||
options.append(true).read(true).create(true);
|
||||
options.read(true).write(true).create(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
options.append(true);
|
||||
options.mode(0o600);
|
||||
}
|
||||
|
||||
@@ -110,6 +120,8 @@ pub(crate) async fn append_entry(
|
||||
// Ensure permissions.
|
||||
ensure_owner_only_permissions(&history_file).await?;
|
||||
|
||||
let history_max_bytes = config.history.max_bytes;
|
||||
|
||||
// Perform a blocking write under an advisory write lock using std::fs.
|
||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
// Retry a few times to avoid indefinite blocking when contended.
|
||||
@@ -117,8 +129,12 @@ pub(crate) async fn append_entry(
|
||||
match history_file.try_lock() {
|
||||
Ok(()) => {
|
||||
// While holding the exclusive lock, write the full line.
|
||||
// We do not open the file with `append(true)` on Windows, so ensure the
|
||||
// cursor is positioned at the end before writing.
|
||||
history_file.seek(SeekFrom::End(0))?;
|
||||
history_file.write_all(line.as_bytes())?;
|
||||
history_file.flush()?;
|
||||
enforce_history_limit(&mut history_file, history_max_bytes)?;
|
||||
return Ok(());
|
||||
}
|
||||
Err(std::fs::TryLockError::WouldBlock) => {
|
||||
@@ -138,27 +154,144 @@ pub(crate) async fn append_entry(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Trim the history file to honor `max_bytes`, dropping the oldest lines while holding
|
||||
/// the write lock so the newest entry is always retained. When the file exceeds the
|
||||
/// hard cap, it rewrites the remaining tail to a soft cap to avoid trimming again
|
||||
/// immediately on the next write.
|
||||
fn enforce_history_limit(file: &mut File, max_bytes: Option<usize>) -> Result<()> {
|
||||
let Some(max_bytes) = max_bytes else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if max_bytes == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let max_bytes = match u64::try_from(max_bytes) {
|
||||
Ok(value) => value,
|
||||
Err(_) => return Ok(()),
|
||||
};
|
||||
|
||||
let mut current_len = file.metadata()?.len();
|
||||
|
||||
if current_len <= max_bytes {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut reader_file = file.try_clone()?;
|
||||
reader_file.seek(SeekFrom::Start(0))?;
|
||||
|
||||
let mut buf_reader = BufReader::new(reader_file);
|
||||
let mut line_lengths = Vec::new();
|
||||
let mut line_buf = String::new();
|
||||
|
||||
loop {
|
||||
line_buf.clear();
|
||||
|
||||
let bytes = buf_reader.read_line(&mut line_buf)?;
|
||||
|
||||
if bytes == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
line_lengths.push(bytes as u64);
|
||||
}
|
||||
|
||||
if line_lengths.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let last_index = line_lengths.len() - 1;
|
||||
let trim_target = trim_target_bytes(max_bytes, line_lengths[last_index]);
|
||||
|
||||
let mut drop_bytes = 0u64;
|
||||
let mut idx = 0usize;
|
||||
|
||||
while current_len > trim_target && idx < last_index {
|
||||
current_len = current_len.saturating_sub(line_lengths[idx]);
|
||||
drop_bytes += line_lengths[idx];
|
||||
idx += 1;
|
||||
}
|
||||
|
||||
if drop_bytes == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut reader = buf_reader.into_inner();
|
||||
reader.seek(SeekFrom::Start(drop_bytes))?;
|
||||
|
||||
let capacity = usize::try_from(current_len).unwrap_or(0);
|
||||
let mut tail = Vec::with_capacity(capacity);
|
||||
|
||||
reader.read_to_end(&mut tail)?;
|
||||
|
||||
file.set_len(0)?;
|
||||
file.seek(SeekFrom::Start(0))?;
|
||||
file.write_all(&tail)?;
|
||||
file.flush()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn trim_target_bytes(max_bytes: u64, newest_entry_len: u64) -> u64 {
|
||||
let soft_cap_bytes = ((max_bytes as f64) * HISTORY_SOFT_CAP_RATIO)
|
||||
.floor()
|
||||
.clamp(1.0, max_bytes as f64) as u64;
|
||||
|
||||
soft_cap_bytes.max(newest_entry_len)
|
||||
}
|
||||
|
||||
/// Asynchronously fetch the history file's *identifier* (inode on Unix) and
|
||||
/// the current number of entries by counting newline characters.
|
||||
pub(crate) async fn history_metadata(config: &Config) -> (u64, usize) {
|
||||
let path = history_filepath(config);
|
||||
history_metadata_for_file(&path).await
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
let log_id = {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
// Obtain metadata (async) to get the identifier.
|
||||
let meta = match fs::metadata(&path).await {
|
||||
Ok(m) => m,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return (0, 0),
|
||||
Err(_) => return (0, 0),
|
||||
};
|
||||
meta.ino()
|
||||
/// Given a `log_id` (on Unix this is the file's inode number,
|
||||
/// on Windows this is the file's creation time) and a zero-based
|
||||
/// `offset`, return the corresponding `HistoryEntry` if the identifier matches
|
||||
/// the current history file **and** the requested offset exists. Any I/O or
|
||||
/// parsing errors are logged and result in `None`.
|
||||
///
|
||||
/// Note this function is not async because it uses a sync advisory file
|
||||
/// locking API.
|
||||
pub(crate) fn lookup(log_id: u64, offset: usize, config: &Config) -> Option<HistoryEntry> {
|
||||
let path = history_filepath(config);
|
||||
lookup_history_entry(&path, log_id, offset)
|
||||
}
|
||||
|
||||
/// On Unix systems, ensure the file permissions are `0o600` (rw-------). If the
|
||||
/// permissions cannot be changed the error is propagated to the caller.
|
||||
#[cfg(unix)]
|
||||
async fn ensure_owner_only_permissions(file: &File) -> Result<()> {
|
||||
let metadata = file.metadata()?;
|
||||
let current_mode = metadata.permissions().mode() & 0o777;
|
||||
if current_mode != 0o600 {
|
||||
let mut perms = metadata.permissions();
|
||||
perms.set_mode(0o600);
|
||||
let perms_clone = perms.clone();
|
||||
let file_clone = file.try_clone()?;
|
||||
tokio::task::spawn_blocking(move || file_clone.set_permissions(perms_clone)).await??;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
// On Windows, simply succeed.
|
||||
async fn ensure_owner_only_permissions(_file: &File) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn history_metadata_for_file(path: &Path) -> (u64, usize) {
|
||||
let log_id = match fs::metadata(path).await {
|
||||
Ok(metadata) => history_log_id(&metadata).unwrap_or(0),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return (0, 0),
|
||||
Err(_) => return (0, 0),
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let log_id = 0u64;
|
||||
|
||||
// Open the file.
|
||||
let mut file = match fs::File::open(&path).await {
|
||||
let mut file = match fs::File::open(path).await {
|
||||
Ok(f) => f,
|
||||
Err(_) => return (log_id, 0),
|
||||
};
|
||||
@@ -179,21 +312,11 @@ pub(crate) async fn history_metadata(config: &Config) -> (u64, usize) {
|
||||
(log_id, count)
|
||||
}
|
||||
|
||||
/// Given a `log_id` (on Unix this is the file's inode number) and a zero-based
|
||||
/// `offset`, return the corresponding `HistoryEntry` if the identifier matches
|
||||
/// the current history file **and** the requested offset exists. Any I/O or
|
||||
/// parsing errors are logged and result in `None`.
|
||||
///
|
||||
/// Note this function is not async because it uses a sync advisory file
|
||||
/// locking API.
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn lookup(log_id: u64, offset: usize, config: &Config) -> Option<HistoryEntry> {
|
||||
fn lookup_history_entry(path: &Path, log_id: u64, offset: usize) -> Option<HistoryEntry> {
|
||||
use std::io::BufRead;
|
||||
use std::io::BufReader;
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
|
||||
let path = history_filepath(config);
|
||||
let file: File = match OpenOptions::new().read(true).open(&path) {
|
||||
let file: File = match OpenOptions::new().read(true).open(path) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "failed to open history file");
|
||||
@@ -209,7 +332,9 @@ pub(crate) fn lookup(log_id: u64, offset: usize, config: &Config) -> Option<Hist
|
||||
}
|
||||
};
|
||||
|
||||
if metadata.ino() != log_id {
|
||||
let current_log_id = history_log_id(&metadata)?;
|
||||
|
||||
if log_id != 0 && current_log_id != log_id {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -256,31 +381,238 @@ pub(crate) fn lookup(log_id: u64, offset: usize, config: &Config) -> Option<Hist
|
||||
None
|
||||
}
|
||||
|
||||
/// Fallback stub for non-Unix systems: currently always returns `None`.
|
||||
#[cfg(not(unix))]
|
||||
pub(crate) fn lookup(log_id: u64, offset: usize, config: &Config) -> Option<HistoryEntry> {
|
||||
let _ = (log_id, offset, config);
|
||||
#[cfg(unix)]
|
||||
fn history_log_id(metadata: &std::fs::Metadata) -> Option<u64> {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
Some(metadata.ino())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn history_log_id(metadata: &std::fs::Metadata) -> Option<u64> {
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
Some(metadata.creation_time())
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn history_log_id(_metadata: &std::fs::Metadata) -> Option<u64> {
|
||||
None
|
||||
}
|
||||
|
||||
/// On Unix systems ensure the file permissions are `0o600` (rw-------). If the
|
||||
/// permissions cannot be changed the error is propagated to the caller.
|
||||
#[cfg(unix)]
|
||||
async fn ensure_owner_only_permissions(file: &File) -> Result<()> {
|
||||
let metadata = file.metadata()?;
|
||||
let current_mode = metadata.permissions().mode() & 0o777;
|
||||
if current_mode != 0o600 {
|
||||
let mut perms = metadata.permissions();
|
||||
perms.set_mode(0o600);
|
||||
let perms_clone = perms.clone();
|
||||
let file_clone = file.try_clone()?;
|
||||
tokio::task::spawn_blocking(move || file_clone.set_permissions(perms_clone)).await??;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::config::ConfigOverrides;
|
||||
use crate::config::ConfigToml;
|
||||
use codex_protocol::ConversationId;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[cfg(not(unix))]
|
||||
async fn ensure_owner_only_permissions(_file: &File) -> Result<()> {
|
||||
// For now, on non-Unix, simply succeed.
|
||||
Ok(())
|
||||
#[tokio::test]
|
||||
async fn lookup_reads_history_entries() {
|
||||
let temp_dir = TempDir::new().expect("create temp dir");
|
||||
let history_path = temp_dir.path().join(HISTORY_FILENAME);
|
||||
|
||||
let entries = vec![
|
||||
HistoryEntry {
|
||||
session_id: "first-session".to_string(),
|
||||
ts: 1,
|
||||
text: "first".to_string(),
|
||||
},
|
||||
HistoryEntry {
|
||||
session_id: "second-session".to_string(),
|
||||
ts: 2,
|
||||
text: "second".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
let mut file = File::create(&history_path).expect("create history file");
|
||||
for entry in &entries {
|
||||
writeln!(
|
||||
file,
|
||||
"{}",
|
||||
serde_json::to_string(entry).expect("serialize history entry")
|
||||
)
|
||||
.expect("write history entry");
|
||||
}
|
||||
|
||||
let (log_id, count) = history_metadata_for_file(&history_path).await;
|
||||
assert_eq!(count, entries.len());
|
||||
|
||||
let second_entry =
|
||||
lookup_history_entry(&history_path, log_id, 1).expect("fetch second history entry");
|
||||
assert_eq!(second_entry, entries[1]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lookup_uses_stable_log_id_after_appends() {
|
||||
let temp_dir = TempDir::new().expect("create temp dir");
|
||||
let history_path = temp_dir.path().join(HISTORY_FILENAME);
|
||||
|
||||
let initial = HistoryEntry {
|
||||
session_id: "first-session".to_string(),
|
||||
ts: 1,
|
||||
text: "first".to_string(),
|
||||
};
|
||||
let appended = HistoryEntry {
|
||||
session_id: "second-session".to_string(),
|
||||
ts: 2,
|
||||
text: "second".to_string(),
|
||||
};
|
||||
|
||||
let mut file = File::create(&history_path).expect("create history file");
|
||||
writeln!(
|
||||
file,
|
||||
"{}",
|
||||
serde_json::to_string(&initial).expect("serialize initial entry")
|
||||
)
|
||||
.expect("write initial entry");
|
||||
|
||||
let (log_id, count) = history_metadata_for_file(&history_path).await;
|
||||
assert_eq!(count, 1);
|
||||
|
||||
let mut append = std::fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&history_path)
|
||||
.expect("open history file for append");
|
||||
writeln!(
|
||||
append,
|
||||
"{}",
|
||||
serde_json::to_string(&appended).expect("serialize appended entry")
|
||||
)
|
||||
.expect("append history entry");
|
||||
|
||||
let fetched =
|
||||
lookup_history_entry(&history_path, log_id, 1).expect("lookup appended history entry");
|
||||
assert_eq!(fetched, appended);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn append_entry_trims_history_when_beyond_max_bytes() {
|
||||
let codex_home = TempDir::new().expect("create temp dir");
|
||||
|
||||
let mut config = Config::load_from_base_config_with_overrides(
|
||||
ConfigToml::default(),
|
||||
ConfigOverrides::default(),
|
||||
codex_home.path().to_path_buf(),
|
||||
)
|
||||
.expect("load config");
|
||||
|
||||
let conversation_id = ConversationId::new();
|
||||
|
||||
let entry_one = "a".repeat(200);
|
||||
let entry_two = "b".repeat(200);
|
||||
|
||||
let history_path = codex_home.path().join("history.jsonl");
|
||||
|
||||
append_entry(&entry_one, &conversation_id, &config)
|
||||
.await
|
||||
.expect("write first entry");
|
||||
|
||||
let first_len = std::fs::metadata(&history_path).expect("metadata").len();
|
||||
let limit_bytes = first_len + 10;
|
||||
|
||||
config.history.max_bytes =
|
||||
Some(usize::try_from(limit_bytes).expect("limit should fit into usize"));
|
||||
|
||||
append_entry(&entry_two, &conversation_id, &config)
|
||||
.await
|
||||
.expect("write second entry");
|
||||
|
||||
let contents = std::fs::read_to_string(&history_path).expect("read history");
|
||||
|
||||
let entries = contents
|
||||
.lines()
|
||||
.map(|line| serde_json::from_str::<HistoryEntry>(line).expect("parse entry"))
|
||||
.collect::<Vec<HistoryEntry>>();
|
||||
|
||||
assert_eq!(
|
||||
entries.len(),
|
||||
1,
|
||||
"only one entry left because entry_one should be evicted"
|
||||
);
|
||||
assert_eq!(entries[0].text, entry_two);
|
||||
assert!(std::fs::metadata(&history_path).expect("metadata").len() <= limit_bytes);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn append_entry_trims_history_to_soft_cap() {
|
||||
let codex_home = TempDir::new().expect("create temp dir");
|
||||
|
||||
let mut config = Config::load_from_base_config_with_overrides(
|
||||
ConfigToml::default(),
|
||||
ConfigOverrides::default(),
|
||||
codex_home.path().to_path_buf(),
|
||||
)
|
||||
.expect("load config");
|
||||
|
||||
let conversation_id = ConversationId::new();
|
||||
|
||||
let short_entry = "a".repeat(200);
|
||||
let long_entry = "b".repeat(400);
|
||||
|
||||
let history_path = codex_home.path().join("history.jsonl");
|
||||
|
||||
append_entry(&short_entry, &conversation_id, &config)
|
||||
.await
|
||||
.expect("write first entry");
|
||||
|
||||
let short_entry_len = std::fs::metadata(&history_path).expect("metadata").len();
|
||||
|
||||
append_entry(&long_entry, &conversation_id, &config)
|
||||
.await
|
||||
.expect("write second entry");
|
||||
|
||||
let two_entry_len = std::fs::metadata(&history_path).expect("metadata").len();
|
||||
|
||||
let long_entry_len = two_entry_len
|
||||
.checked_sub(short_entry_len)
|
||||
.expect("second entry length should be larger than first entry length");
|
||||
|
||||
config.history.max_bytes = Some(
|
||||
usize::try_from((2 * long_entry_len) + (short_entry_len / 2))
|
||||
.expect("max bytes should fit into usize"),
|
||||
);
|
||||
|
||||
append_entry(&long_entry, &conversation_id, &config)
|
||||
.await
|
||||
.expect("write third entry");
|
||||
|
||||
let contents = std::fs::read_to_string(&history_path).expect("read history");
|
||||
|
||||
let entries = contents
|
||||
.lines()
|
||||
.map(|line| serde_json::from_str::<HistoryEntry>(line).expect("parse entry"))
|
||||
.collect::<Vec<HistoryEntry>>();
|
||||
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].text, long_entry);
|
||||
|
||||
let pruned_len = std::fs::metadata(&history_path).expect("metadata").len();
|
||||
let max_bytes = config
|
||||
.history
|
||||
.max_bytes
|
||||
.expect("max bytes should be configured") as u64;
|
||||
|
||||
assert!(pruned_len <= max_bytes);
|
||||
|
||||
let soft_cap_bytes = ((max_bytes as f64) * HISTORY_SOFT_CAP_RATIO)
|
||||
.floor()
|
||||
.clamp(1.0, max_bytes as f64) as u64;
|
||||
let len_without_first = 2 * long_entry_len;
|
||||
|
||||
assert!(
|
||||
len_without_first <= max_bytes,
|
||||
"dropping only the first entry would satisfy the hard cap"
|
||||
);
|
||||
assert!(
|
||||
len_without_first > soft_cap_bytes,
|
||||
"soft cap should require more aggressive trimming than the hard cap"
|
||||
);
|
||||
|
||||
assert_eq!(pruned_len, long_entry_len);
|
||||
assert!(pruned_len <= soft_cap_bytes.max(long_entry_len));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,8 @@ pub(crate) fn get_model_info(model_family: &ModelFamily) -> Option<ModelInfo> {
|
||||
|
||||
_ if slug.starts_with("codex-") => Some(ModelInfo::new(CONTEXT_WINDOW_272K)),
|
||||
|
||||
_ if slug.starts_with("exp-") => Some(ModelInfo::new(CONTEXT_WINDOW_272K)),
|
||||
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
//! 3. We do **not** walk past the Git root.
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::features::Feature;
|
||||
use crate::skills::load_skills;
|
||||
use crate::skills::render_skills_section;
|
||||
use dunce::canonicalize as normalize_path;
|
||||
use std::path::PathBuf;
|
||||
use tokio::io::AsyncReadExt;
|
||||
@@ -31,18 +34,47 @@ const PROJECT_DOC_SEPARATOR: &str = "\n\n--- project-doc ---\n\n";
|
||||
/// Combines `Config::instructions` and `AGENTS.md` (if present) into a single
|
||||
/// string of instructions.
|
||||
pub(crate) async fn get_user_instructions(config: &Config) -> Option<String> {
|
||||
match read_project_docs(config).await {
|
||||
Ok(Some(project_doc)) => match &config.user_instructions {
|
||||
Some(original_instructions) => Some(format!(
|
||||
"{original_instructions}{PROJECT_DOC_SEPARATOR}{project_doc}"
|
||||
)),
|
||||
None => Some(project_doc),
|
||||
},
|
||||
Ok(None) => config.user_instructions.clone(),
|
||||
let skills_section = if config.features.enabled(Feature::Skills) {
|
||||
let skills_outcome = load_skills(config);
|
||||
for err in &skills_outcome.errors {
|
||||
error!(
|
||||
"failed to load skill {}: {}",
|
||||
err.path.display(),
|
||||
err.message
|
||||
);
|
||||
}
|
||||
render_skills_section(&skills_outcome.skills)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let project_docs = match read_project_docs(config).await {
|
||||
Ok(docs) => docs,
|
||||
Err(e) => {
|
||||
error!("error trying to find project doc: {e:#}");
|
||||
config.user_instructions.clone()
|
||||
return config.user_instructions.clone();
|
||||
}
|
||||
};
|
||||
|
||||
let combined_project_docs = merge_project_docs_with_skills(project_docs, skills_section);
|
||||
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
|
||||
if let Some(instructions) = config.user_instructions.clone() {
|
||||
parts.push(instructions);
|
||||
}
|
||||
|
||||
if let Some(project_doc) = combined_project_docs {
|
||||
if !parts.is_empty() {
|
||||
parts.push(PROJECT_DOC_SEPARATOR.to_string());
|
||||
}
|
||||
parts.push(project_doc);
|
||||
}
|
||||
|
||||
if parts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(parts.concat())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,12 +227,25 @@ fn candidate_filenames<'a>(config: &'a Config) -> Vec<&'a str> {
|
||||
names
|
||||
}
|
||||
|
||||
fn merge_project_docs_with_skills(
|
||||
project_doc: Option<String>,
|
||||
skills_section: Option<String>,
|
||||
) -> Option<String> {
|
||||
match (project_doc, skills_section) {
|
||||
(Some(doc), Some(skills)) => Some(format!("{doc}\n\n{skills}")),
|
||||
(Some(doc), None) => Some(doc),
|
||||
(None, Some(skills)) => Some(skills),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::ConfigOverrides;
|
||||
use crate::config::ConfigToml;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Helper that returns a `Config` pointing at `root` and using `limit` as
|
||||
@@ -219,6 +264,7 @@ mod tests {
|
||||
|
||||
config.cwd = root.path().to_path_buf();
|
||||
config.project_doc_max_bytes = limit;
|
||||
config.features.enable(Feature::Skills);
|
||||
|
||||
config.user_instructions = instructions.map(ToOwned::to_owned);
|
||||
config
|
||||
@@ -447,4 +493,58 @@ mod tests {
|
||||
.eq(DEFAULT_PROJECT_DOC_FILENAME)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skills_are_appended_to_project_doc() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
fs::write(tmp.path().join("AGENTS.md"), "base doc").unwrap();
|
||||
|
||||
let cfg = make_config(&tmp, 4096, None);
|
||||
create_skill(
|
||||
cfg.codex_home.clone(),
|
||||
"pdf-processing",
|
||||
"extract from pdfs",
|
||||
);
|
||||
|
||||
let res = get_user_instructions(&cfg)
|
||||
.await
|
||||
.expect("instructions expected");
|
||||
let expected_path = dunce::canonicalize(
|
||||
cfg.codex_home
|
||||
.join("skills/pdf-processing/SKILL.md")
|
||||
.as_path(),
|
||||
)
|
||||
.unwrap_or_else(|_| cfg.codex_home.join("skills/pdf-processing/SKILL.md"));
|
||||
let expected_path_str = expected_path.to_string_lossy().replace('\\', "/");
|
||||
let expected = format!(
|
||||
"base doc\n\n## Skills\nThese skills are discovered at startup from ~/.codex/skills; each entry shows name, description, and file path so you can open the source for full instructions. Content is not inlined to keep context lean.\n- pdf-processing: extract from pdfs (file: {expected_path_str})"
|
||||
);
|
||||
assert_eq!(res, expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skills_render_without_project_doc() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let cfg = make_config(&tmp, 4096, None);
|
||||
create_skill(cfg.codex_home.clone(), "linting", "run clippy");
|
||||
|
||||
let res = get_user_instructions(&cfg)
|
||||
.await
|
||||
.expect("instructions expected");
|
||||
let expected_path =
|
||||
dunce::canonicalize(cfg.codex_home.join("skills/linting/SKILL.md").as_path())
|
||||
.unwrap_or_else(|_| cfg.codex_home.join("skills/linting/SKILL.md"));
|
||||
let expected_path_str = expected_path.to_string_lossy().replace('\\', "/");
|
||||
let expected = format!(
|
||||
"## Skills\nThese skills are discovered at startup from ~/.codex/skills; each entry shows name, description, and file path so you can open the source for full instructions. Content is not inlined to keep context lean.\n- linting: run clippy (file: {expected_path_str})"
|
||||
);
|
||||
assert_eq!(res, expected);
|
||||
}
|
||||
|
||||
fn create_skill(codex_home: PathBuf, name: &str, description: &str) {
|
||||
let skill_dir = codex_home.join(format!("skills/{name}"));
|
||||
fs::create_dir_all(&skill_dir).unwrap();
|
||||
let content = format!("---\nname: {name}\ndescription: {description}\n---\n\n# Body\n");
|
||||
fs::write(skill_dir.join("SKILL.md"), content).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::protocol::ReviewFinding;
|
||||
use crate::protocol::ReviewOutputEvent;
|
||||
|
||||
// Note: We keep this module UI-agnostic. It returns plain strings that
|
||||
// higher layers (e.g., TUI) may style as needed.
|
||||
@@ -10,6 +11,8 @@ fn format_location(item: &ReviewFinding) -> String {
|
||||
format!("{path}:{start}-{end}")
|
||||
}
|
||||
|
||||
const REVIEW_FALLBACK_MESSAGE: &str = "Reviewer failed to output a response.";
|
||||
|
||||
/// Format a full review findings block as plain text lines.
|
||||
///
|
||||
/// - When `selection` is `Some`, each item line includes a checkbox marker:
|
||||
@@ -53,3 +56,27 @@ pub fn format_review_findings_block(
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
/// Render a human-readable review summary suitable for a user-facing message.
|
||||
///
|
||||
/// Returns either the explanation, the formatted findings block, or both
|
||||
/// separated by a blank line. If neither is present, emits a fallback message.
|
||||
pub fn render_review_output_text(output: &ReviewOutputEvent) -> String {
|
||||
let mut sections = Vec::new();
|
||||
let explanation = output.overall_explanation.trim();
|
||||
if !explanation.is_empty() {
|
||||
sections.push(explanation.to_string());
|
||||
}
|
||||
if !output.findings.is_empty() {
|
||||
let findings = format_review_findings_block(&output.findings, None);
|
||||
let trimmed = findings.trim();
|
||||
if !trimmed.is_empty() {
|
||||
sections.push(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
if sections.is_empty() {
|
||||
REVIEW_FALLBACK_MESSAGE.to_string()
|
||||
} else {
|
||||
sections.join("\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
93
codex-rs/core/src/review_prompts.rs
Normal file
93
codex-rs/core/src/review_prompts.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
use codex_git::merge_base_with_head;
|
||||
use codex_protocol::protocol::ReviewRequest;
|
||||
use codex_protocol::protocol::ReviewTarget;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ResolvedReviewRequest {
|
||||
pub target: ReviewTarget,
|
||||
pub prompt: String,
|
||||
pub user_facing_hint: String,
|
||||
}
|
||||
|
||||
const UNCOMMITTED_PROMPT: &str = "Review the current code changes (staged, unstaged, and untracked files) and provide prioritized findings.";
|
||||
|
||||
const BASE_BRANCH_PROMPT_BACKUP: &str = "Review the code changes against the base branch '{branch}'. Start by finding the merge diff between the current branch and {branch}'s upstream e.g. (`git merge-base HEAD \"$(git rev-parse --abbrev-ref \"{branch}@{upstream}\")\"`), then run `git diff` against that SHA to see what changes we would merge into the {branch} branch. Provide prioritized, actionable findings.";
|
||||
const BASE_BRANCH_PROMPT: &str = "Review the code changes against the base branch '{baseBranch}'. The merge base commit for this comparison is {mergeBaseSha}. Run `git diff {mergeBaseSha}` to inspect the changes relative to {baseBranch}. Provide prioritized, actionable findings.";
|
||||
|
||||
const COMMIT_PROMPT_WITH_TITLE: &str = "Review the code changes introduced by commit {sha} (\"{title}\"). Provide prioritized, actionable findings.";
|
||||
const COMMIT_PROMPT: &str =
|
||||
"Review the code changes introduced by commit {sha}. Provide prioritized, actionable findings.";
|
||||
|
||||
pub fn resolve_review_request(
|
||||
request: ReviewRequest,
|
||||
cwd: &Path,
|
||||
) -> anyhow::Result<ResolvedReviewRequest> {
|
||||
let target = request.target;
|
||||
let prompt = review_prompt(&target, cwd)?;
|
||||
let user_facing_hint = request
|
||||
.user_facing_hint
|
||||
.unwrap_or_else(|| user_facing_hint(&target));
|
||||
|
||||
Ok(ResolvedReviewRequest {
|
||||
target,
|
||||
prompt,
|
||||
user_facing_hint,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn review_prompt(target: &ReviewTarget, cwd: &Path) -> anyhow::Result<String> {
|
||||
match target {
|
||||
ReviewTarget::UncommittedChanges => Ok(UNCOMMITTED_PROMPT.to_string()),
|
||||
ReviewTarget::BaseBranch { branch } => {
|
||||
if let Some(commit) = merge_base_with_head(cwd, branch)? {
|
||||
Ok(BASE_BRANCH_PROMPT
|
||||
.replace("{baseBranch}", branch)
|
||||
.replace("{mergeBaseSha}", &commit))
|
||||
} else {
|
||||
Ok(BASE_BRANCH_PROMPT_BACKUP.replace("{branch}", branch))
|
||||
}
|
||||
}
|
||||
ReviewTarget::Commit { sha, title } => {
|
||||
if let Some(title) = title {
|
||||
Ok(COMMIT_PROMPT_WITH_TITLE
|
||||
.replace("{sha}", sha)
|
||||
.replace("{title}", title))
|
||||
} else {
|
||||
Ok(COMMIT_PROMPT.replace("{sha}", sha))
|
||||
}
|
||||
}
|
||||
ReviewTarget::Custom { instructions } => {
|
||||
let prompt = instructions.trim();
|
||||
if prompt.is_empty() {
|
||||
anyhow::bail!("Review prompt cannot be empty");
|
||||
}
|
||||
Ok(prompt.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn user_facing_hint(target: &ReviewTarget) -> String {
|
||||
match target {
|
||||
ReviewTarget::UncommittedChanges => "current changes".to_string(),
|
||||
ReviewTarget::BaseBranch { branch } => format!("changes against '{branch}'"),
|
||||
ReviewTarget::Commit { sha, title } => {
|
||||
let short_sha: String = sha.chars().take(7).collect();
|
||||
if let Some(title) = title {
|
||||
format!("commit {short_sha}: {title}")
|
||||
} else {
|
||||
format!("commit {short_sha}")
|
||||
}
|
||||
}
|
||||
ReviewTarget::Custom { instructions } => instructions.trim().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ResolvedReviewRequest> for ReviewRequest {
|
||||
fn from(resolved: ResolvedReviewRequest) -> Self {
|
||||
ReviewRequest {
|
||||
target: resolved.target,
|
||||
user_facing_hint: Some(resolved.user_facing_hint),
|
||||
}
|
||||
}
|
||||
}
|
||||
49
codex-rs/core/src/rollout/error.rs
Normal file
49
codex-rs/core/src/rollout/error.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
use std::io::ErrorKind;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::error::CodexErr;
|
||||
use crate::rollout::SESSIONS_SUBDIR;
|
||||
|
||||
pub(crate) fn map_session_init_error(err: &anyhow::Error, codex_home: &Path) -> CodexErr {
|
||||
if let Some(mapped) = err
|
||||
.chain()
|
||||
.filter_map(|cause| cause.downcast_ref::<std::io::Error>())
|
||||
.find_map(|io_err| map_rollout_io_error(io_err, codex_home))
|
||||
{
|
||||
return mapped;
|
||||
}
|
||||
|
||||
CodexErr::Fatal(format!("Failed to initialize session: {err:#}"))
|
||||
}
|
||||
|
||||
fn map_rollout_io_error(io_err: &std::io::Error, codex_home: &Path) -> Option<CodexErr> {
|
||||
let sessions_dir = codex_home.join(SESSIONS_SUBDIR);
|
||||
let hint = match io_err.kind() {
|
||||
ErrorKind::PermissionDenied => format!(
|
||||
"Codex cannot access session files at {} (permission denied). If sessions were created using sudo, fix ownership: sudo chown -R $(whoami) {}",
|
||||
sessions_dir.display(),
|
||||
codex_home.display()
|
||||
),
|
||||
ErrorKind::NotFound => format!(
|
||||
"Session storage missing at {}. Create the directory or choose a different Codex home.",
|
||||
sessions_dir.display()
|
||||
),
|
||||
ErrorKind::AlreadyExists => format!(
|
||||
"Session storage path {} is blocked by an existing file. Remove or rename it so Codex can create sessions.",
|
||||
sessions_dir.display()
|
||||
),
|
||||
ErrorKind::InvalidData | ErrorKind::InvalidInput => format!(
|
||||
"Session data under {} looks corrupt or unreadable. Clearing the sessions directory may help (this will remove saved conversations).",
|
||||
sessions_dir.display()
|
||||
),
|
||||
ErrorKind::IsADirectory | ErrorKind::NotADirectory => format!(
|
||||
"Session storage path {} has an unexpected type. Ensure it is a directory Codex can use for session files.",
|
||||
sessions_dir.display()
|
||||
),
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
Some(CodexErr::Fatal(format!(
|
||||
"{hint} (underlying error: {io_err})"
|
||||
)))
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use std::sync::atomic::AtomicBool;
|
||||
use time::OffsetDateTime;
|
||||
use time::PrimitiveDateTime;
|
||||
use time::format_description::FormatItem;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
use time::macros::format_description;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -39,18 +40,15 @@ pub struct ConversationItem {
|
||||
pub path: PathBuf,
|
||||
/// First up to `HEAD_RECORD_LIMIT` JSONL records parsed as JSON (includes meta line).
|
||||
pub head: Vec<serde_json::Value>,
|
||||
/// Last up to `TAIL_RECORD_LIMIT` JSONL response records parsed as JSON.
|
||||
pub tail: Vec<serde_json::Value>,
|
||||
/// RFC3339 timestamp string for when the session was created, if available.
|
||||
pub created_at: Option<String>,
|
||||
/// RFC3339 timestamp string for the most recent response in the tail, if available.
|
||||
/// RFC3339 timestamp string for the most recent update (from file mtime).
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct HeadTailSummary {
|
||||
head: Vec<serde_json::Value>,
|
||||
tail: Vec<serde_json::Value>,
|
||||
saw_session_meta: bool,
|
||||
saw_user_event: bool,
|
||||
source: Option<SessionSource>,
|
||||
@@ -62,7 +60,6 @@ struct HeadTailSummary {
|
||||
/// Hard cap to bound worst‑case work per request.
|
||||
const MAX_SCAN_FILES: usize = 10000;
|
||||
const HEAD_RECORD_LIMIT: usize = 10;
|
||||
const TAIL_RECORD_LIMIT: usize = 10;
|
||||
|
||||
/// Pagination cursor identifying a file by timestamp and UUID.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -141,13 +138,6 @@ pub(crate) async fn get_conversations(
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Load the full contents of a single conversation session file at `path`.
|
||||
/// Returns the entire file contents as a String.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) async fn get_conversation(path: &Path) -> io::Result<String> {
|
||||
tokio::fs::read_to_string(path).await
|
||||
}
|
||||
|
||||
/// Load conversation file paths from disk using directory traversal.
|
||||
///
|
||||
/// Directory layout: `~/.codex/sessions/YYYY/MM/DD/rollout-YYYY-MM-DDThh-mm-ss-<uuid>.jsonl`
|
||||
@@ -212,9 +202,8 @@ async fn traverse_directories_for_paths(
|
||||
more_matches_available = true;
|
||||
break 'outer;
|
||||
}
|
||||
// Read head and simultaneously detect message events within the same
|
||||
// first N JSONL records to avoid a second file read.
|
||||
let summary = read_head_and_tail(&path, HEAD_RECORD_LIMIT, TAIL_RECORD_LIMIT)
|
||||
// Read head and detect message events; stop once meta + user are found.
|
||||
let summary = read_head_summary(&path, HEAD_RECORD_LIMIT)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
if !allowed_sources.is_empty()
|
||||
@@ -233,16 +222,19 @@ async fn traverse_directories_for_paths(
|
||||
if summary.saw_session_meta && summary.saw_user_event {
|
||||
let HeadTailSummary {
|
||||
head,
|
||||
tail,
|
||||
created_at,
|
||||
mut updated_at,
|
||||
..
|
||||
} = summary;
|
||||
updated_at = updated_at.or_else(|| created_at.clone());
|
||||
if updated_at.is_none() {
|
||||
updated_at = file_modified_rfc3339(&path)
|
||||
.await
|
||||
.unwrap_or(None)
|
||||
.or_else(|| created_at.clone());
|
||||
}
|
||||
items.push(ConversationItem {
|
||||
path,
|
||||
head,
|
||||
tail,
|
||||
created_at,
|
||||
updated_at,
|
||||
});
|
||||
@@ -384,11 +376,7 @@ impl<'a> ProviderMatcher<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_head_and_tail(
|
||||
path: &Path,
|
||||
head_limit: usize,
|
||||
tail_limit: usize,
|
||||
) -> io::Result<HeadTailSummary> {
|
||||
async fn read_head_summary(path: &Path, head_limit: usize) -> io::Result<HeadTailSummary> {
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
|
||||
let file = tokio::fs::File::open(path).await?;
|
||||
@@ -441,107 +429,30 @@ async fn read_head_and_tail(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if summary.saw_session_meta && summary.saw_user_event {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if tail_limit != 0 {
|
||||
let (tail, updated_at) = read_tail_records(path, tail_limit).await?;
|
||||
summary.tail = tail;
|
||||
summary.updated_at = updated_at;
|
||||
}
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
/// Read up to `HEAD_RECORD_LIMIT` records from the start of the rollout file at `path`.
|
||||
/// This should be enough to produce a summary including the session meta line.
|
||||
pub async fn read_head_for_summary(path: &Path) -> io::Result<Vec<serde_json::Value>> {
|
||||
let summary = read_head_and_tail(path, HEAD_RECORD_LIMIT, 0).await?;
|
||||
let summary = read_head_summary(path, HEAD_RECORD_LIMIT).await?;
|
||||
Ok(summary.head)
|
||||
}
|
||||
|
||||
async fn read_tail_records(
|
||||
path: &Path,
|
||||
max_records: usize,
|
||||
) -> io::Result<(Vec<serde_json::Value>, Option<String>)> {
|
||||
use std::io::SeekFrom;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::io::AsyncSeekExt;
|
||||
|
||||
if max_records == 0 {
|
||||
return Ok((Vec::new(), None));
|
||||
}
|
||||
|
||||
const CHUNK_SIZE: usize = 8192;
|
||||
|
||||
let mut file = tokio::fs::File::open(path).await?;
|
||||
let mut pos = file.seek(SeekFrom::End(0)).await?;
|
||||
if pos == 0 {
|
||||
return Ok((Vec::new(), None));
|
||||
}
|
||||
|
||||
let mut buffer: Vec<u8> = Vec::new();
|
||||
let mut latest_timestamp: Option<String> = None;
|
||||
|
||||
loop {
|
||||
let slice_start = match (pos > 0, buffer.iter().position(|&b| b == b'\n')) {
|
||||
(true, Some(idx)) => idx + 1,
|
||||
_ => 0,
|
||||
};
|
||||
let (tail, newest_ts) = collect_last_response_values(&buffer[slice_start..], max_records);
|
||||
if latest_timestamp.is_none() {
|
||||
latest_timestamp = newest_ts.clone();
|
||||
}
|
||||
if tail.len() >= max_records || pos == 0 {
|
||||
return Ok((tail, latest_timestamp.or(newest_ts)));
|
||||
}
|
||||
|
||||
let read_size = CHUNK_SIZE.min(pos as usize);
|
||||
if read_size == 0 {
|
||||
return Ok((tail, latest_timestamp.or(newest_ts)));
|
||||
}
|
||||
pos -= read_size as u64;
|
||||
file.seek(SeekFrom::Start(pos)).await?;
|
||||
let mut chunk = vec![0; read_size];
|
||||
file.read_exact(&mut chunk).await?;
|
||||
chunk.extend_from_slice(&buffer);
|
||||
buffer = chunk;
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_last_response_values(
|
||||
buffer: &[u8],
|
||||
max_records: usize,
|
||||
) -> (Vec<serde_json::Value>, Option<String>) {
|
||||
use std::borrow::Cow;
|
||||
|
||||
if buffer.is_empty() || max_records == 0 {
|
||||
return (Vec::new(), None);
|
||||
}
|
||||
|
||||
let text: Cow<'_, str> = String::from_utf8_lossy(buffer);
|
||||
let mut collected_rev: Vec<serde_json::Value> = Vec::new();
|
||||
let mut latest_timestamp: Option<String> = None;
|
||||
for line in text.lines().rev() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let parsed: serde_json::Result<RolloutLine> = serde_json::from_str(trimmed);
|
||||
let Ok(rollout_line) = parsed else { continue };
|
||||
let RolloutLine { timestamp, item } = rollout_line;
|
||||
if let RolloutItem::ResponseItem(item) = item
|
||||
&& let Ok(val) = serde_json::to_value(&item)
|
||||
{
|
||||
if latest_timestamp.is_none() {
|
||||
latest_timestamp = Some(timestamp.clone());
|
||||
}
|
||||
collected_rev.push(val);
|
||||
if collected_rev.len() == max_records {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
collected_rev.reverse();
|
||||
(collected_rev, latest_timestamp)
|
||||
async fn file_modified_rfc3339(path: &Path) -> io::Result<Option<String>> {
|
||||
let meta = tokio::fs::metadata(path).await?;
|
||||
let modified = meta.modified().ok();
|
||||
let Some(modified) = modified else {
|
||||
return Ok(None);
|
||||
};
|
||||
let dt = OffsetDateTime::from(modified);
|
||||
Ok(dt.format(&Rfc3339).ok())
|
||||
}
|
||||
|
||||
/// Locate a recorded conversation rollout file by its UUID string using the existing
|
||||
|
||||
@@ -7,11 +7,13 @@ pub const ARCHIVED_SESSIONS_SUBDIR: &str = "archived_sessions";
|
||||
pub const INTERACTIVE_SESSION_SOURCES: &[SessionSource] =
|
||||
&[SessionSource::Cli, SessionSource::VSCode];
|
||||
|
||||
pub(crate) mod error;
|
||||
pub mod list;
|
||||
pub(crate) mod policy;
|
||||
pub mod recorder;
|
||||
|
||||
pub use codex_protocol::protocol::SessionMeta;
|
||||
pub(crate) use error::map_session_init_error;
|
||||
pub use list::find_conversation_path_by_id_str;
|
||||
pub use recorder::RolloutRecorder;
|
||||
pub use recorder::RolloutRecorderParams;
|
||||
|
||||
@@ -68,6 +68,7 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool {
|
||||
| EventMsg::ElicitationRequest(_)
|
||||
| EventMsg::ApplyPatchApprovalRequest(_)
|
||||
| EventMsg::BackgroundEvent(_)
|
||||
| EventMsg::SaveSessionResponse(_)
|
||||
| EventMsg::StreamError(_)
|
||||
| EventMsg::PatchApplyBegin(_)
|
||||
| EventMsg::PatchApplyEnd(_)
|
||||
|
||||
@@ -11,6 +11,8 @@ use serde_json::Value;
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::FormatItem;
|
||||
use time::macros::format_description;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::io::AsyncSeekExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::sync::mpsc::Sender;
|
||||
use tokio::sync::mpsc::{self};
|
||||
@@ -70,6 +72,10 @@ enum RolloutCmd {
|
||||
Shutdown {
|
||||
ack: oneshot::Sender<()>,
|
||||
},
|
||||
SetName {
|
||||
name: Option<String>,
|
||||
ack: oneshot::Sender<std::io::Result<()>>,
|
||||
},
|
||||
}
|
||||
|
||||
impl RolloutRecorderParams {
|
||||
@@ -148,11 +154,14 @@ impl RolloutRecorder {
|
||||
instructions,
|
||||
source,
|
||||
model_provider: Some(config.model_provider_id.clone()),
|
||||
name: None,
|
||||
}),
|
||||
)
|
||||
}
|
||||
RolloutRecorderParams::Resume { path } => (
|
||||
tokio::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.await?,
|
||||
@@ -196,6 +205,21 @@ impl RolloutRecorder {
|
||||
.map_err(|e| IoError::other(format!("failed to queue rollout items: {e}")))
|
||||
}
|
||||
|
||||
/// Update the session name stored in the rollout's SessionMeta line.
|
||||
pub async fn set_session_name(&self, name: Option<String>) -> std::io::Result<()> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.tx
|
||||
.send(RolloutCmd::SetName { name, ack: tx })
|
||||
.await
|
||||
.map_err(|e| IoError::other(format!("failed to queue session name update: {e}")))?;
|
||||
match rx.await {
|
||||
Ok(result) => result,
|
||||
Err(e) => Err(IoError::other(format!(
|
||||
"failed waiting for session name update: {e}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush all queued writes and wait until they are committed by the writer task.
|
||||
pub async fn flush(&self) -> std::io::Result<()> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
@@ -334,6 +358,7 @@ fn create_log_file(
|
||||
|
||||
let path = dir.join(filename);
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.append(true)
|
||||
.create(true)
|
||||
.open(&path)?;
|
||||
@@ -389,6 +414,10 @@ async fn rollout_writer(
|
||||
RolloutCmd::Shutdown { ack } => {
|
||||
let _ = ack.send(());
|
||||
}
|
||||
RolloutCmd::SetName { name, ack } => {
|
||||
let result = rewrite_session_meta_name(&mut writer.file, name).await;
|
||||
let _ = ack.send(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -422,3 +451,232 @@ impl JsonlWriter {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn rewrite_session_meta_name(
|
||||
file: &mut tokio::fs::File,
|
||||
name: Option<String>,
|
||||
) -> std::io::Result<()> {
|
||||
use std::io::SeekFrom;
|
||||
|
||||
file.flush().await?;
|
||||
file.seek(SeekFrom::Start(0)).await?;
|
||||
let mut contents = Vec::new();
|
||||
file.read_to_end(&mut contents).await?;
|
||||
if contents.is_empty() {
|
||||
return Err(IoError::other("empty rollout file"));
|
||||
}
|
||||
let newline_idx = contents
|
||||
.iter()
|
||||
.position(|&b| b == b'\n')
|
||||
.ok_or_else(|| IoError::other("rollout missing newline after SessionMeta"))?;
|
||||
let first_line = &contents[..newline_idx];
|
||||
let mut rollout_line: RolloutLine = serde_json::from_slice(first_line)
|
||||
.map_err(|e| IoError::other(format!("failed to parse SessionMeta: {e}")))?;
|
||||
let RolloutItem::SessionMeta(ref mut session_meta_line) = rollout_line.item else {
|
||||
return Err(IoError::other("first rollout item is not SessionMeta"));
|
||||
};
|
||||
session_meta_line.meta.name = name;
|
||||
let mut updated = serde_json::to_vec(&rollout_line)?;
|
||||
updated.push(b'\n');
|
||||
updated.extend_from_slice(&contents[newline_idx + 1..]);
|
||||
file.set_len(0).await?;
|
||||
file.seek(SeekFrom::Start(0)).await?;
|
||||
file.write_all(&updated).await?;
|
||||
file.flush().await?;
|
||||
file.seek(SeekFrom::End(0)).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::rewrite_session_meta_name;
|
||||
use codex_protocol::ConversationId;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::RolloutLine;
|
||||
use codex_protocol::protocol::SessionMeta;
|
||||
use codex_protocol::protocol::SessionMetaLine;
|
||||
use tempfile::NamedTempFile;
|
||||
use tokio::fs::OpenOptions;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::io::AsyncSeekExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
fn sample_meta(name: Option<&str>) -> RolloutItem {
|
||||
RolloutItem::SessionMeta(SessionMetaLine {
|
||||
meta: SessionMeta {
|
||||
id: ConversationId::from_string("00000000-0000-4000-8000-000000000001")
|
||||
.expect("conversation id"),
|
||||
timestamp: "2025-01-01T00:00:00.000Z".to_string(),
|
||||
cwd: "/tmp".into(),
|
||||
originator: "tester".to_string(),
|
||||
cli_version: "1.0.0".to_string(),
|
||||
instructions: None,
|
||||
source: codex_protocol::protocol::SessionSource::Cli,
|
||||
model_provider: Some("provider".to_string()),
|
||||
name: name.map(str::to_string),
|
||||
},
|
||||
git: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn sample_line() -> RolloutLine {
|
||||
RolloutLine {
|
||||
timestamp: "2025-01-01T00:00:00.000Z".to_string(),
|
||||
item: sample_meta(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_rollout(lines: &[RolloutLine]) -> (NamedTempFile, tokio::fs::File) {
|
||||
let temp = NamedTempFile::new().expect("temp file");
|
||||
let mut file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.create(true)
|
||||
.open(temp.path())
|
||||
.await
|
||||
.expect("open temp file");
|
||||
for line in lines {
|
||||
let mut json = serde_json::to_vec(line).expect("serialize line");
|
||||
json.push(b'\n');
|
||||
file.write_all(&json).await.expect("write line");
|
||||
}
|
||||
file.seek(std::io::SeekFrom::Start(0))
|
||||
.await
|
||||
.expect("rewind");
|
||||
(temp, file)
|
||||
}
|
||||
|
||||
async fn read_first_line(path: &std::path::Path) -> RolloutLine {
|
||||
let mut contents = String::new();
|
||||
let mut file = OpenOptions::new()
|
||||
.read(true)
|
||||
.open(path)
|
||||
.await
|
||||
.expect("open for read");
|
||||
file.read_to_string(&mut contents).await.expect("read file");
|
||||
let first = contents.lines().next().expect("first line");
|
||||
serde_json::from_str(first).expect("parse first line")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn updates_meta_name_and_preserves_rest() {
|
||||
let events = vec![
|
||||
sample_line(),
|
||||
RolloutLine {
|
||||
timestamp: "2025-01-01T00:00:01.000Z".to_string(),
|
||||
item: RolloutItem::ResponseItem(ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: "hello".to_string(),
|
||||
}],
|
||||
}),
|
||||
},
|
||||
];
|
||||
let (temp, mut file) = write_rollout(&events).await;
|
||||
|
||||
rewrite_session_meta_name(&mut file, Some("renamed".to_string()))
|
||||
.await
|
||||
.expect("rewrite ok");
|
||||
|
||||
let first = read_first_line(temp.path()).await;
|
||||
let RolloutItem::SessionMeta(meta_line) = first.item else {
|
||||
panic!("expected SessionMeta line");
|
||||
};
|
||||
assert_eq!(meta_line.meta.name.as_deref(), Some("renamed"));
|
||||
|
||||
let contents = tokio::fs::read_to_string(temp.path())
|
||||
.await
|
||||
.expect("read file");
|
||||
let lines: Vec<_> = contents.lines().collect();
|
||||
assert_eq!(lines.len(), 2);
|
||||
let parsed: RolloutLine = serde_json::from_str(lines[1]).expect("parse second line");
|
||||
let RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. }) = parsed.item
|
||||
else {
|
||||
panic!("expected response item");
|
||||
};
|
||||
assert_eq!(role, "assistant");
|
||||
assert_eq!(
|
||||
content,
|
||||
vec![ContentItem::OutputText {
|
||||
text: "hello".to_string()
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clearing_name_sets_none() {
|
||||
let mut first = sample_line();
|
||||
first.item = sample_meta(Some("existing"));
|
||||
let (temp, mut file) = write_rollout(&[first]).await;
|
||||
|
||||
rewrite_session_meta_name(&mut file, None)
|
||||
.await
|
||||
.expect("rewrite ok");
|
||||
|
||||
let first = read_first_line(temp.path()).await;
|
||||
let RolloutItem::SessionMeta(meta_line) = first.item else {
|
||||
panic!("expected SessionMeta line");
|
||||
};
|
||||
assert_eq!(meta_line.meta.name, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn errors_on_empty_file() {
|
||||
let temp = NamedTempFile::new().expect("temp file");
|
||||
let mut file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(temp.path())
|
||||
.await
|
||||
.expect("open temp file");
|
||||
let err = rewrite_session_meta_name(&mut file, Some("x".to_string()))
|
||||
.await
|
||||
.expect_err("expected error");
|
||||
assert!(format!("{err}").contains("empty rollout file"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn errors_when_first_line_not_session_meta() {
|
||||
let wrong = RolloutLine {
|
||||
timestamp: "t".to_string(),
|
||||
item: RolloutItem::ResponseItem(ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: "hello".to_string(),
|
||||
}],
|
||||
}),
|
||||
};
|
||||
let (_temp, mut file) = write_rollout(&[wrong]).await;
|
||||
let err = rewrite_session_meta_name(&mut file, Some("x".to_string()))
|
||||
.await
|
||||
.expect_err("expected error");
|
||||
assert!(format!("{err}").contains("first rollout item is not SessionMeta"));
|
||||
// ensure file pointer is rewound to end after failure paths
|
||||
let pos = file
|
||||
.seek(std::io::SeekFrom::Current(0))
|
||||
.await
|
||||
.expect("seek");
|
||||
assert!(pos > 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn errors_when_missing_newline() {
|
||||
let temp = NamedTempFile::new().expect("temp file");
|
||||
let mut file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(temp.path())
|
||||
.await
|
||||
.expect("open temp file");
|
||||
file.write_all(b"no newline").await.expect("write");
|
||||
let err = rewrite_session_meta_name(&mut file, Some("x".to_string()))
|
||||
.await
|
||||
.expect_err("expected error");
|
||||
assert!(format!("{err}").contains("rollout missing newline after SessionMeta"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,13 +16,11 @@ use crate::rollout::INTERACTIVE_SESSION_SOURCES;
|
||||
use crate::rollout::list::ConversationItem;
|
||||
use crate::rollout::list::ConversationsPage;
|
||||
use crate::rollout::list::Cursor;
|
||||
use crate::rollout::list::get_conversation;
|
||||
use crate::rollout::list::get_conversations;
|
||||
use anyhow::Result;
|
||||
use codex_protocol::ConversationId;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::CompactedItem;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::RolloutLine;
|
||||
@@ -226,28 +224,28 @@ async fn test_list_conversations_latest_first() {
|
||||
"model_provider": "test-provider",
|
||||
})];
|
||||
|
||||
let updated_times: Vec<Option<String>> =
|
||||
page.items.iter().map(|i| i.updated_at.clone()).collect();
|
||||
|
||||
let expected = ConversationsPage {
|
||||
items: vec![
|
||||
ConversationItem {
|
||||
path: p1,
|
||||
head: head_3,
|
||||
tail: Vec::new(),
|
||||
created_at: Some("2025-01-03T12-00-00".into()),
|
||||
updated_at: Some("2025-01-03T12-00-00".into()),
|
||||
updated_at: updated_times.first().cloned().flatten(),
|
||||
},
|
||||
ConversationItem {
|
||||
path: p2,
|
||||
head: head_2,
|
||||
tail: Vec::new(),
|
||||
created_at: Some("2025-01-02T12-00-00".into()),
|
||||
updated_at: Some("2025-01-02T12-00-00".into()),
|
||||
updated_at: updated_times.get(1).cloned().flatten(),
|
||||
},
|
||||
ConversationItem {
|
||||
path: p3,
|
||||
head: head_1,
|
||||
tail: Vec::new(),
|
||||
created_at: Some("2025-01-01T12-00-00".into()),
|
||||
updated_at: Some("2025-01-01T12-00-00".into()),
|
||||
updated_at: updated_times.get(2).cloned().flatten(),
|
||||
},
|
||||
],
|
||||
next_cursor: None,
|
||||
@@ -355,6 +353,8 @@ async fn test_pagination_cursor() {
|
||||
"source": "vscode",
|
||||
"model_provider": "test-provider",
|
||||
})];
|
||||
let updated_page1: Vec<Option<String>> =
|
||||
page1.items.iter().map(|i| i.updated_at.clone()).collect();
|
||||
let expected_cursor1: Cursor =
|
||||
serde_json::from_str(&format!("\"2025-03-04T09-00-00|{u4}\"")).unwrap();
|
||||
let expected_page1 = ConversationsPage {
|
||||
@@ -362,16 +362,14 @@ async fn test_pagination_cursor() {
|
||||
ConversationItem {
|
||||
path: p5,
|
||||
head: head_5,
|
||||
tail: Vec::new(),
|
||||
created_at: Some("2025-03-05T09-00-00".into()),
|
||||
updated_at: Some("2025-03-05T09-00-00".into()),
|
||||
updated_at: updated_page1.first().cloned().flatten(),
|
||||
},
|
||||
ConversationItem {
|
||||
path: p4,
|
||||
head: head_4,
|
||||
tail: Vec::new(),
|
||||
created_at: Some("2025-03-04T09-00-00".into()),
|
||||
updated_at: Some("2025-03-04T09-00-00".into()),
|
||||
updated_at: updated_page1.get(1).cloned().flatten(),
|
||||
},
|
||||
],
|
||||
next_cursor: Some(expected_cursor1.clone()),
|
||||
@@ -422,6 +420,8 @@ async fn test_pagination_cursor() {
|
||||
"source": "vscode",
|
||||
"model_provider": "test-provider",
|
||||
})];
|
||||
let updated_page2: Vec<Option<String>> =
|
||||
page2.items.iter().map(|i| i.updated_at.clone()).collect();
|
||||
let expected_cursor2: Cursor =
|
||||
serde_json::from_str(&format!("\"2025-03-02T09-00-00|{u2}\"")).unwrap();
|
||||
let expected_page2 = ConversationsPage {
|
||||
@@ -429,16 +429,14 @@ async fn test_pagination_cursor() {
|
||||
ConversationItem {
|
||||
path: p3,
|
||||
head: head_3,
|
||||
tail: Vec::new(),
|
||||
created_at: Some("2025-03-03T09-00-00".into()),
|
||||
updated_at: Some("2025-03-03T09-00-00".into()),
|
||||
updated_at: updated_page2.first().cloned().flatten(),
|
||||
},
|
||||
ConversationItem {
|
||||
path: p2,
|
||||
head: head_2,
|
||||
tail: Vec::new(),
|
||||
created_at: Some("2025-03-02T09-00-00".into()),
|
||||
updated_at: Some("2025-03-02T09-00-00".into()),
|
||||
updated_at: updated_page2.get(1).cloned().flatten(),
|
||||
},
|
||||
],
|
||||
next_cursor: Some(expected_cursor2.clone()),
|
||||
@@ -473,13 +471,14 @@ async fn test_pagination_cursor() {
|
||||
"source": "vscode",
|
||||
"model_provider": "test-provider",
|
||||
})];
|
||||
let updated_page3: Vec<Option<String>> =
|
||||
page3.items.iter().map(|i| i.updated_at.clone()).collect();
|
||||
let expected_page3 = ConversationsPage {
|
||||
items: vec![ConversationItem {
|
||||
path: p1,
|
||||
head: head_1,
|
||||
tail: Vec::new(),
|
||||
created_at: Some("2025-03-01T09-00-00".into()),
|
||||
updated_at: Some("2025-03-01T09-00-00".into()),
|
||||
updated_at: updated_page3.first().cloned().flatten(),
|
||||
}],
|
||||
next_cursor: None,
|
||||
num_scanned_files: 5, // scanned 05, 04 (anchor), 03, 02 (anchor), 01
|
||||
@@ -510,7 +509,7 @@ async fn test_get_conversation_contents() {
|
||||
.unwrap();
|
||||
let path = &page.items[0].path;
|
||||
|
||||
let content = get_conversation(path).await.unwrap();
|
||||
let content = tokio::fs::read_to_string(path).await.unwrap();
|
||||
|
||||
// Page equality (single item)
|
||||
let expected_path = home
|
||||
@@ -533,9 +532,8 @@ async fn test_get_conversation_contents() {
|
||||
items: vec![ConversationItem {
|
||||
path: expected_path,
|
||||
head: expected_head,
|
||||
tail: Vec::new(),
|
||||
created_at: Some(ts.into()),
|
||||
updated_at: Some(ts.into()),
|
||||
updated_at: page.items[0].updated_at.clone(),
|
||||
}],
|
||||
next_cursor: None,
|
||||
num_scanned_files: 1,
|
||||
@@ -570,7 +568,7 @@ async fn test_get_conversation_contents() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tail_includes_last_response_items() -> Result<()> {
|
||||
async fn test_updated_at_uses_file_mtime() -> Result<()> {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let home = temp.path();
|
||||
|
||||
@@ -594,6 +592,7 @@ async fn test_tail_includes_last_response_items() -> Result<()> {
|
||||
cli_version: "test_version".into(),
|
||||
source: SessionSource::VSCode,
|
||||
model_provider: Some("test-provider".into()),
|
||||
name: None,
|
||||
},
|
||||
git: None,
|
||||
}),
|
||||
@@ -636,229 +635,16 @@ async fn test_tail_includes_last_response_items() -> Result<()> {
|
||||
)
|
||||
.await?;
|
||||
let item = page.items.first().expect("conversation item");
|
||||
let tail_len = item.tail.len();
|
||||
assert_eq!(tail_len, 10usize.min(total_messages));
|
||||
|
||||
let expected: Vec<serde_json::Value> = (total_messages - tail_len..total_messages)
|
||||
.map(|idx| {
|
||||
serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": format!("reply-{idx}"),
|
||||
}
|
||||
],
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert_eq!(item.tail, expected);
|
||||
assert_eq!(item.created_at.as_deref(), Some(ts));
|
||||
let expected_updated = format!("{ts}-{last:02}", last = total_messages - 1);
|
||||
assert_eq!(item.updated_at.as_deref(), Some(expected_updated.as_str()));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tail_handles_short_sessions() -> Result<()> {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let home = temp.path();
|
||||
|
||||
let ts = "2025-06-02T08-30-00";
|
||||
let uuid = Uuid::from_u128(7);
|
||||
let day_dir = home.join("sessions").join("2025").join("06").join("02");
|
||||
fs::create_dir_all(&day_dir)?;
|
||||
let file_path = day_dir.join(format!("rollout-{ts}-{uuid}.jsonl"));
|
||||
let mut file = File::create(&file_path)?;
|
||||
|
||||
let conversation_id = ConversationId::from_string(&uuid.to_string())?;
|
||||
let meta_line = RolloutLine {
|
||||
timestamp: ts.to_string(),
|
||||
item: RolloutItem::SessionMeta(SessionMetaLine {
|
||||
meta: SessionMeta {
|
||||
id: conversation_id,
|
||||
timestamp: ts.to_string(),
|
||||
instructions: None,
|
||||
cwd: ".".into(),
|
||||
originator: "test_originator".into(),
|
||||
cli_version: "test_version".into(),
|
||||
source: SessionSource::VSCode,
|
||||
model_provider: Some("test-provider".into()),
|
||||
},
|
||||
git: None,
|
||||
}),
|
||||
};
|
||||
writeln!(file, "{}", serde_json::to_string(&meta_line)?)?;
|
||||
|
||||
let user_event_line = RolloutLine {
|
||||
timestamp: ts.to_string(),
|
||||
item: RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
|
||||
message: "hi".into(),
|
||||
images: None,
|
||||
})),
|
||||
};
|
||||
writeln!(file, "{}", serde_json::to_string(&user_event_line)?)?;
|
||||
|
||||
for idx in 0..3 {
|
||||
let response_line = RolloutLine {
|
||||
timestamp: format!("{ts}-{idx:02}"),
|
||||
item: RolloutItem::ResponseItem(ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".into(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: format!("short-{idx}"),
|
||||
}],
|
||||
}),
|
||||
};
|
||||
writeln!(file, "{}", serde_json::to_string(&response_line)?)?;
|
||||
}
|
||||
drop(file);
|
||||
|
||||
let provider_filter = provider_vec(&[TEST_PROVIDER]);
|
||||
let page = get_conversations(
|
||||
home,
|
||||
1,
|
||||
None,
|
||||
INTERACTIVE_SESSION_SOURCES,
|
||||
Some(provider_filter.as_slice()),
|
||||
TEST_PROVIDER,
|
||||
)
|
||||
.await?;
|
||||
let tail = &page.items.first().expect("conversation item").tail;
|
||||
|
||||
assert_eq!(tail.len(), 3);
|
||||
|
||||
let expected: Vec<serde_json::Value> = (0..3)
|
||||
.map(|idx| {
|
||||
serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": format!("short-{idx}"),
|
||||
}
|
||||
],
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert_eq!(tail, &expected);
|
||||
let expected_updated = format!("{ts}-{last:02}", last = 2);
|
||||
assert_eq!(
|
||||
page.items[0].updated_at.as_deref(),
|
||||
Some(expected_updated.as_str())
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tail_skips_trailing_non_responses() -> Result<()> {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let home = temp.path();
|
||||
|
||||
let ts = "2025-06-03T10-00-00";
|
||||
let uuid = Uuid::from_u128(11);
|
||||
let day_dir = home.join("sessions").join("2025").join("06").join("03");
|
||||
fs::create_dir_all(&day_dir)?;
|
||||
let file_path = day_dir.join(format!("rollout-{ts}-{uuid}.jsonl"));
|
||||
let mut file = File::create(&file_path)?;
|
||||
|
||||
let conversation_id = ConversationId::from_string(&uuid.to_string())?;
|
||||
let meta_line = RolloutLine {
|
||||
timestamp: ts.to_string(),
|
||||
item: RolloutItem::SessionMeta(SessionMetaLine {
|
||||
meta: SessionMeta {
|
||||
id: conversation_id,
|
||||
timestamp: ts.to_string(),
|
||||
instructions: None,
|
||||
cwd: ".".into(),
|
||||
originator: "test_originator".into(),
|
||||
cli_version: "test_version".into(),
|
||||
source: SessionSource::VSCode,
|
||||
model_provider: Some("test-provider".into()),
|
||||
},
|
||||
git: None,
|
||||
}),
|
||||
};
|
||||
writeln!(file, "{}", serde_json::to_string(&meta_line)?)?;
|
||||
|
||||
let user_event_line = RolloutLine {
|
||||
timestamp: ts.to_string(),
|
||||
item: RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
|
||||
message: "hello".into(),
|
||||
images: None,
|
||||
})),
|
||||
};
|
||||
writeln!(file, "{}", serde_json::to_string(&user_event_line)?)?;
|
||||
|
||||
for idx in 0..4 {
|
||||
let response_line = RolloutLine {
|
||||
timestamp: format!("{ts}-{idx:02}"),
|
||||
item: RolloutItem::ResponseItem(ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".into(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: format!("response-{idx}"),
|
||||
}],
|
||||
}),
|
||||
};
|
||||
writeln!(file, "{}", serde_json::to_string(&response_line)?)?;
|
||||
}
|
||||
|
||||
let compacted_line = RolloutLine {
|
||||
timestamp: format!("{ts}-compacted"),
|
||||
item: RolloutItem::Compacted(CompactedItem {
|
||||
message: "compacted".into(),
|
||||
replacement_history: None,
|
||||
}),
|
||||
};
|
||||
writeln!(file, "{}", serde_json::to_string(&compacted_line)?)?;
|
||||
|
||||
let shutdown_event = RolloutLine {
|
||||
timestamp: format!("{ts}-shutdown"),
|
||||
item: RolloutItem::EventMsg(EventMsg::ShutdownComplete),
|
||||
};
|
||||
writeln!(file, "{}", serde_json::to_string(&shutdown_event)?)?;
|
||||
drop(file);
|
||||
|
||||
let provider_filter = provider_vec(&[TEST_PROVIDER]);
|
||||
let page = get_conversations(
|
||||
home,
|
||||
1,
|
||||
None,
|
||||
INTERACTIVE_SESSION_SOURCES,
|
||||
Some(provider_filter.as_slice()),
|
||||
TEST_PROVIDER,
|
||||
)
|
||||
.await?;
|
||||
let tail = &page.items.first().expect("conversation item").tail;
|
||||
|
||||
let expected: Vec<serde_json::Value> = (0..4)
|
||||
.map(|idx| {
|
||||
serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": format!("response-{idx}"),
|
||||
}
|
||||
],
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert_eq!(tail, &expected);
|
||||
let expected_updated = format!("{ts}-{last:02}", last = 3);
|
||||
assert_eq!(
|
||||
page.items[0].updated_at.as_deref(),
|
||||
Some(expected_updated.as_str())
|
||||
);
|
||||
let updated = item
|
||||
.updated_at
|
||||
.as_deref()
|
||||
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
|
||||
.map(|dt| dt.with_timezone(&chrono::Utc))
|
||||
.expect("updated_at set from file mtime");
|
||||
let now = chrono::Utc::now();
|
||||
let age = now - updated;
|
||||
assert!(age.num_seconds().abs() < 30);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -913,22 +699,22 @@ async fn test_stable_ordering_same_second_pagination() {
|
||||
"model_provider": "test-provider",
|
||||
})]
|
||||
};
|
||||
let updated_page1: Vec<Option<String>> =
|
||||
page1.items.iter().map(|i| i.updated_at.clone()).collect();
|
||||
let expected_cursor1: Cursor = serde_json::from_str(&format!("\"{ts}|{u2}\"")).unwrap();
|
||||
let expected_page1 = ConversationsPage {
|
||||
items: vec![
|
||||
ConversationItem {
|
||||
path: p3,
|
||||
head: head(u3),
|
||||
tail: Vec::new(),
|
||||
created_at: Some(ts.to_string()),
|
||||
updated_at: Some(ts.to_string()),
|
||||
updated_at: updated_page1.first().cloned().flatten(),
|
||||
},
|
||||
ConversationItem {
|
||||
path: p2,
|
||||
head: head(u2),
|
||||
tail: Vec::new(),
|
||||
created_at: Some(ts.to_string()),
|
||||
updated_at: Some(ts.to_string()),
|
||||
updated_at: updated_page1.get(1).cloned().flatten(),
|
||||
},
|
||||
],
|
||||
next_cursor: Some(expected_cursor1.clone()),
|
||||
@@ -953,13 +739,14 @@ async fn test_stable_ordering_same_second_pagination() {
|
||||
.join("07")
|
||||
.join("01")
|
||||
.join(format!("rollout-2025-07-01T00-00-00-{u1}.jsonl"));
|
||||
let updated_page2: Vec<Option<String>> =
|
||||
page2.items.iter().map(|i| i.updated_at.clone()).collect();
|
||||
let expected_page2 = ConversationsPage {
|
||||
items: vec![ConversationItem {
|
||||
path: p1,
|
||||
head: head(u1),
|
||||
tail: Vec::new(),
|
||||
created_at: Some(ts.to_string()),
|
||||
updated_at: Some(ts.to_string()),
|
||||
updated_at: updated_page2.first().cloned().flatten(),
|
||||
}],
|
||||
next_cursor: None,
|
||||
num_scanned_files: 3, // scanned u3, u2 (anchor), u1
|
||||
|
||||
@@ -6,6 +6,7 @@ use codex_apply_patch::ApplyPatchAction;
|
||||
use codex_apply_patch::ApplyPatchFileChange;
|
||||
|
||||
use crate::exec::SandboxType;
|
||||
use crate::util::resolve_path;
|
||||
|
||||
use crate::protocol::AskForApproval;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
@@ -150,11 +151,7 @@ fn is_write_patch_constrained_to_writable_paths(
|
||||
// and roots are converted to absolute, normalized forms before the
|
||||
// prefix check.
|
||||
let is_path_writable = |p: &PathBuf| {
|
||||
let abs = if p.is_absolute() {
|
||||
p.clone()
|
||||
} else {
|
||||
cwd.join(p)
|
||||
};
|
||||
let abs = resolve_path(cwd, p);
|
||||
let abs = match normalize(&abs) {
|
||||
Some(v) => v,
|
||||
None => return false,
|
||||
|
||||
@@ -14,6 +14,7 @@ use crate::protocol::SandboxPolicy;
|
||||
use askama::Template;
|
||||
use codex_otel::otel_event_manager::OtelEventManager;
|
||||
use codex_protocol::ConversationId;
|
||||
use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::SandboxCommandAssessment;
|
||||
@@ -23,7 +24,8 @@ use serde_json::json;
|
||||
use tokio::time::timeout;
|
||||
use tracing::warn;
|
||||
|
||||
const SANDBOX_ASSESSMENT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const SANDBOX_ASSESSMENT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const SANDBOX_ASSESSMENT_REASONING_EFFORT: ReasoningEffortConfig = ReasoningEffortConfig::Medium;
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "sandboxing/assessment_prompt.md", escape = "none")]
|
||||
@@ -130,7 +132,7 @@ pub(crate) async fn assess_command(
|
||||
Some(auth_manager),
|
||||
child_otel,
|
||||
provider,
|
||||
config.model_reasoning_effort,
|
||||
Some(SANDBOX_ASSESSMENT_REASONING_EFFORT),
|
||||
config.model_reasoning_summary,
|
||||
conversation_id,
|
||||
session_source,
|
||||
|
||||
144
codex-rs/core/src/saved_sessions.rs
Normal file
144
codex-rs/core/src/saved_sessions.rs
Normal file
@@ -0,0 +1,144 @@
|
||||
use crate::error::Result;
|
||||
use crate::find_conversation_path_by_id_str;
|
||||
use crate::rollout::list::read_head_for_summary;
|
||||
use codex_protocol::ConversationId;
|
||||
use codex_protocol::protocol::SessionMetaLine;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Error as IoError;
|
||||
use std::io::ErrorKind;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
use tracing::warn;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SavedSessionEntry {
|
||||
pub name: String,
|
||||
pub conversation_id: ConversationId,
|
||||
pub rollout_path: PathBuf,
|
||||
pub cwd: PathBuf,
|
||||
pub model: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model_provider: Option<String>,
|
||||
pub saved_at: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub created_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SavedSessionsFile {
|
||||
#[serde(default)]
|
||||
entries: BTreeMap<String, SavedSessionEntry>,
|
||||
}
|
||||
|
||||
fn saved_sessions_path(codex_home: &Path) -> PathBuf {
|
||||
codex_home.join("saved_sessions.json")
|
||||
}
|
||||
|
||||
async fn load_saved_sessions_file(path: &Path) -> Result<SavedSessionsFile> {
|
||||
match tokio::fs::read_to_string(path).await {
|
||||
Ok(text) => {
|
||||
let parsed = serde_json::from_str(&text)
|
||||
.map_err(|e| IoError::other(format!("failed to parse saved sessions: {e}")))?;
|
||||
Ok(parsed)
|
||||
}
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => Ok(SavedSessionsFile::default()),
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_saved_sessions_file(path: &Path, file: &SavedSessionsFile) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(file)
|
||||
.map_err(|e| IoError::other(format!("failed to serialize saved sessions: {e}")))?;
|
||||
let tmp_path = path.with_extension("json.tmp");
|
||||
tokio::fs::write(&tmp_path, json).await?;
|
||||
tokio::fs::rename(tmp_path, path).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a new entry from the rollout's SessionMeta line.
|
||||
pub async fn build_saved_session_entry(
|
||||
name: String,
|
||||
rollout_path: PathBuf,
|
||||
model: String,
|
||||
) -> Result<SavedSessionEntry> {
|
||||
let head = read_head_for_summary(&rollout_path).await?;
|
||||
let first = head.first().ok_or_else(|| {
|
||||
IoError::other(format!(
|
||||
"rollout at {} has no SessionMeta",
|
||||
rollout_path.display()
|
||||
))
|
||||
})?;
|
||||
let SessionMetaLine { mut meta, .. } = serde_json::from_value::<SessionMetaLine>(first.clone())
|
||||
.map_err(|e| IoError::other(format!("failed to parse SessionMeta: {e}")))?;
|
||||
meta.name = Some(name.clone());
|
||||
let saved_at = OffsetDateTime::now_utc()
|
||||
.format(&Rfc3339)
|
||||
.map_err(|e| IoError::other(format!("failed to format timestamp: {e}")))?;
|
||||
let created_at = if meta.timestamp.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(meta.timestamp.clone())
|
||||
};
|
||||
Ok(SavedSessionEntry {
|
||||
name,
|
||||
conversation_id: meta.id,
|
||||
rollout_path,
|
||||
cwd: meta.cwd,
|
||||
model,
|
||||
model_provider: meta.model_provider,
|
||||
saved_at,
|
||||
created_at,
|
||||
})
|
||||
}
|
||||
|
||||
/// Insert or replace a saved session entry in `saved_sessions.json`.
|
||||
pub async fn upsert_saved_session(codex_home: &Path, entry: SavedSessionEntry) -> Result<()> {
|
||||
let path = saved_sessions_path(codex_home);
|
||||
let mut file = load_saved_sessions_file(&path).await?;
|
||||
file.entries.insert(entry.name.clone(), entry);
|
||||
write_saved_sessions_file(&path, &file).await
|
||||
}
|
||||
|
||||
/// Lookup a saved session by name, if present.
|
||||
pub async fn resolve_saved_session(
|
||||
codex_home: &Path,
|
||||
name: &str,
|
||||
) -> Result<Option<SavedSessionEntry>> {
|
||||
let path = saved_sessions_path(codex_home);
|
||||
let file = load_saved_sessions_file(&path).await?;
|
||||
Ok(file.entries.get(name).cloned())
|
||||
}
|
||||
|
||||
/// Return all saved sessions ordered by newest `saved_at` first.
|
||||
pub async fn list_saved_sessions(codex_home: &Path) -> Result<Vec<SavedSessionEntry>> {
|
||||
let path = saved_sessions_path(codex_home);
|
||||
let file = load_saved_sessions_file(&path).await?;
|
||||
let mut entries: Vec<SavedSessionEntry> = file.entries.values().cloned().collect();
|
||||
entries.sort_by(|a, b| b.saved_at.cmp(&a.saved_at));
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Resolve a rollout path from either a saved-session name or rollout id string.
|
||||
/// Returns `Ok(None)` when nothing matches.
|
||||
pub async fn resolve_rollout_path(codex_home: &Path, identifier: &str) -> Result<Option<PathBuf>> {
|
||||
if let Some(entry) = resolve_saved_session(codex_home, identifier).await? {
|
||||
if entry.rollout_path.exists() {
|
||||
return Ok(Some(entry.rollout_path));
|
||||
}
|
||||
warn!(
|
||||
"saved session '{}' points to missing rollout at {}",
|
||||
identifier,
|
||||
entry.rollout_path.display()
|
||||
);
|
||||
}
|
||||
Ok(find_conversation_path_by_id_str(codex_home, identifier).await?)
|
||||
}
|
||||
291
codex-rs/core/src/skills/loader.rs
Normal file
291
codex-rs/core/src/skills/loader.rs
Normal file
@@ -0,0 +1,291 @@
|
||||
use crate::config::Config;
|
||||
use crate::skills::model::SkillError;
|
||||
use crate::skills::model::SkillLoadOutcome;
|
||||
use crate::skills::model::SkillMetadata;
|
||||
use dunce::canonicalize as normalize_path;
|
||||
use serde::Deserialize;
|
||||
use std::collections::VecDeque;
|
||||
use std::error::Error;
|
||||
use std::fmt;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tracing::error;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SkillFrontmatter {
|
||||
name: String,
|
||||
description: String,
|
||||
}
|
||||
|
||||
const SKILLS_FILENAME: &str = "SKILL.md";
|
||||
const SKILLS_DIR_NAME: &str = "skills";
|
||||
const MAX_NAME_LEN: usize = 100;
|
||||
const MAX_DESCRIPTION_LEN: usize = 500;
|
||||
|
||||
#[derive(Debug)]
|
||||
enum SkillParseError {
|
||||
Read(std::io::Error),
|
||||
MissingFrontmatter,
|
||||
InvalidYaml(serde_yaml::Error),
|
||||
MissingField(&'static str),
|
||||
InvalidField { field: &'static str, reason: String },
|
||||
}
|
||||
|
||||
impl fmt::Display for SkillParseError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
SkillParseError::Read(e) => write!(f, "failed to read file: {e}"),
|
||||
SkillParseError::MissingFrontmatter => {
|
||||
write!(f, "missing YAML frontmatter delimited by ---")
|
||||
}
|
||||
SkillParseError::InvalidYaml(e) => write!(f, "invalid YAML: {e}"),
|
||||
SkillParseError::MissingField(field) => write!(f, "missing field `{field}`"),
|
||||
SkillParseError::InvalidField { field, reason } => {
|
||||
write!(f, "invalid {field}: {reason}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for SkillParseError {}
|
||||
|
||||
pub fn load_skills(config: &Config) -> SkillLoadOutcome {
|
||||
let mut outcome = SkillLoadOutcome::default();
|
||||
let roots = skill_roots(config);
|
||||
for root in roots {
|
||||
discover_skills_under_root(&root, &mut outcome);
|
||||
}
|
||||
|
||||
outcome
|
||||
.skills
|
||||
.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.path.cmp(&b.path)));
|
||||
|
||||
outcome
|
||||
}
|
||||
|
||||
fn skill_roots(config: &Config) -> Vec<PathBuf> {
|
||||
vec![config.codex_home.join(SKILLS_DIR_NAME)]
|
||||
}
|
||||
|
||||
fn discover_skills_under_root(root: &Path, outcome: &mut SkillLoadOutcome) {
|
||||
let Ok(root) = normalize_path(root) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if !root.is_dir() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut queue: VecDeque<PathBuf> = VecDeque::from([root]);
|
||||
while let Some(dir) = queue.pop_front() {
|
||||
let entries = match fs::read_dir(&dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(e) => {
|
||||
error!("failed to read skills dir {}: {e:#}", dir.display());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let file_name = match path.file_name().and_then(|f| f.to_str()) {
|
||||
Some(name) => name,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
if file_name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Ok(file_type) = entry.file_type() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if file_type.is_symlink() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if file_type.is_dir() {
|
||||
queue.push_back(path);
|
||||
continue;
|
||||
}
|
||||
|
||||
if file_type.is_file() && file_name == SKILLS_FILENAME {
|
||||
match parse_skill_file(&path) {
|
||||
Ok(skill) => outcome.skills.push(skill),
|
||||
Err(err) => outcome.errors.push(SkillError {
|
||||
path,
|
||||
message: err.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_skill_file(path: &Path) -> Result<SkillMetadata, SkillParseError> {
|
||||
let contents = fs::read_to_string(path).map_err(SkillParseError::Read)?;
|
||||
|
||||
let frontmatter = extract_frontmatter(&contents).ok_or(SkillParseError::MissingFrontmatter)?;
|
||||
|
||||
let parsed: SkillFrontmatter =
|
||||
serde_yaml::from_str(&frontmatter).map_err(SkillParseError::InvalidYaml)?;
|
||||
|
||||
let name = sanitize_single_line(&parsed.name);
|
||||
let description = sanitize_single_line(&parsed.description);
|
||||
|
||||
validate_field(&name, MAX_NAME_LEN, "name")?;
|
||||
validate_field(&description, MAX_DESCRIPTION_LEN, "description")?;
|
||||
|
||||
let resolved_path = normalize_path(path).unwrap_or_else(|_| path.to_path_buf());
|
||||
|
||||
Ok(SkillMetadata {
|
||||
name,
|
||||
description,
|
||||
path: resolved_path,
|
||||
})
|
||||
}
|
||||
|
||||
fn sanitize_single_line(raw: &str) -> String {
|
||||
raw.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
fn validate_field(
|
||||
value: &str,
|
||||
max_len: usize,
|
||||
field_name: &'static str,
|
||||
) -> Result<(), SkillParseError> {
|
||||
if value.is_empty() {
|
||||
return Err(SkillParseError::MissingField(field_name));
|
||||
}
|
||||
if value.len() > max_len {
|
||||
return Err(SkillParseError::InvalidField {
|
||||
field: field_name,
|
||||
reason: format!("exceeds maximum length of {max_len} characters"),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_frontmatter(contents: &str) -> Option<String> {
|
||||
let mut lines = contents.lines();
|
||||
if !matches!(lines.next(), Some(line) if line.trim() == "---") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut frontmatter_lines: Vec<&str> = Vec::new();
|
||||
let mut found_closing = false;
|
||||
for line in lines.by_ref() {
|
||||
if line.trim() == "---" {
|
||||
found_closing = true;
|
||||
break;
|
||||
}
|
||||
frontmatter_lines.push(line);
|
||||
}
|
||||
|
||||
if frontmatter_lines.is_empty() || !found_closing {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(frontmatter_lines.join("\n"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::ConfigOverrides;
|
||||
use crate::config::ConfigToml;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn make_config(codex_home: &TempDir) -> Config {
|
||||
let mut config = Config::load_from_base_config_with_overrides(
|
||||
ConfigToml::default(),
|
||||
ConfigOverrides::default(),
|
||||
codex_home.path().to_path_buf(),
|
||||
)
|
||||
.expect("defaults for test should always succeed");
|
||||
|
||||
config.cwd = codex_home.path().to_path_buf();
|
||||
config
|
||||
}
|
||||
|
||||
fn write_skill(codex_home: &TempDir, dir: &str, name: &str, description: &str) -> PathBuf {
|
||||
let skill_dir = codex_home.path().join(format!("skills/{dir}"));
|
||||
fs::create_dir_all(&skill_dir).unwrap();
|
||||
let indented_description = description.replace('\n', "\n ");
|
||||
let content = format!(
|
||||
"---\nname: {name}\ndescription: |-\n {indented_description}\n---\n\n# Body\n"
|
||||
);
|
||||
let path = skill_dir.join(SKILLS_FILENAME);
|
||||
fs::write(&path, content).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_valid_skill() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
write_skill(&codex_home, "demo", "demo-skill", "does things\ncarefully");
|
||||
let cfg = make_config(&codex_home);
|
||||
|
||||
let outcome = load_skills(&cfg);
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
"unexpected errors: {:?}",
|
||||
outcome.errors
|
||||
);
|
||||
assert_eq!(outcome.skills.len(), 1);
|
||||
let skill = &outcome.skills[0];
|
||||
assert_eq!(skill.name, "demo-skill");
|
||||
assert_eq!(skill.description, "does things carefully");
|
||||
let path_str = skill.path.to_string_lossy().replace('\\', "/");
|
||||
assert!(
|
||||
path_str.ends_with("skills/demo/SKILL.md"),
|
||||
"unexpected path {path_str}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_hidden_and_invalid() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
let hidden_dir = codex_home.path().join("skills/.hidden");
|
||||
fs::create_dir_all(&hidden_dir).unwrap();
|
||||
fs::write(
|
||||
hidden_dir.join(SKILLS_FILENAME),
|
||||
"---\nname: hidden\ndescription: hidden\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Invalid because missing closing frontmatter.
|
||||
let invalid_dir = codex_home.path().join("skills/invalid");
|
||||
fs::create_dir_all(&invalid_dir).unwrap();
|
||||
fs::write(invalid_dir.join(SKILLS_FILENAME), "---\nname: bad").unwrap();
|
||||
|
||||
let cfg = make_config(&codex_home);
|
||||
let outcome = load_skills(&cfg);
|
||||
assert_eq!(outcome.skills.len(), 0);
|
||||
assert_eq!(outcome.errors.len(), 1);
|
||||
assert!(
|
||||
outcome.errors[0]
|
||||
.message
|
||||
.contains("missing YAML frontmatter"),
|
||||
"expected frontmatter error"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enforces_length_limits() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
let long_desc = "a".repeat(MAX_DESCRIPTION_LEN + 1);
|
||||
write_skill(&codex_home, "too-long", "toolong", &long_desc);
|
||||
let cfg = make_config(&codex_home);
|
||||
|
||||
let outcome = load_skills(&cfg);
|
||||
assert_eq!(outcome.skills.len(), 0);
|
||||
assert_eq!(outcome.errors.len(), 1);
|
||||
assert!(
|
||||
outcome.errors[0].message.contains("invalid description"),
|
||||
"expected length error"
|
||||
);
|
||||
}
|
||||
}
|
||||
9
codex-rs/core/src/skills/mod.rs
Normal file
9
codex-rs/core/src/skills/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
pub mod loader;
|
||||
pub mod model;
|
||||
pub mod render;
|
||||
|
||||
pub use loader::load_skills;
|
||||
pub use model::SkillError;
|
||||
pub use model::SkillLoadOutcome;
|
||||
pub use model::SkillMetadata;
|
||||
pub use render::render_skills_section;
|
||||
20
codex-rs/core/src/skills/model.rs
Normal file
20
codex-rs/core/src/skills/model.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SkillMetadata {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SkillError {
|
||||
pub path: PathBuf,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SkillLoadOutcome {
|
||||
pub skills: Vec<SkillMetadata>,
|
||||
pub errors: Vec<SkillError>,
|
||||
}
|
||||
21
codex-rs/core/src/skills/render.rs
Normal file
21
codex-rs/core/src/skills/render.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use crate::skills::model::SkillMetadata;
|
||||
|
||||
pub fn render_skills_section(skills: &[SkillMetadata]) -> Option<String> {
|
||||
if skills.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
lines.push("## Skills".to_string());
|
||||
lines.push("These skills are discovered at startup from ~/.codex/skills; each entry shows name, description, and file path so you can open the source for full instructions. Content is not inlined to keep context lean.".to_string());
|
||||
|
||||
for skill in skills {
|
||||
let path_str = skill.path.to_string_lossy().replace('\\', "/");
|
||||
lines.push(format!(
|
||||
"- {}: {} (file: {})",
|
||||
skill.name, skill.description, path_str
|
||||
));
|
||||
}
|
||||
|
||||
Some(lines.join("\n"))
|
||||
}
|
||||
@@ -62,7 +62,10 @@ impl SessionState {
|
||||
}
|
||||
|
||||
pub(crate) fn set_rate_limits(&mut self, snapshot: RateLimitSnapshot) {
|
||||
self.latest_rate_limits = Some(snapshot);
|
||||
self.latest_rate_limits = Some(merge_rate_limit_credits(
|
||||
self.latest_rate_limits.as_ref(),
|
||||
snapshot,
|
||||
));
|
||||
}
|
||||
|
||||
pub(crate) fn token_info_and_rate_limits(
|
||||
@@ -79,3 +82,14 @@ impl SessionState {
|
||||
self.history.get_total_token_usage()
|
||||
}
|
||||
}
|
||||
|
||||
// Sometimes new snapshots don't include credits
|
||||
fn merge_rate_limit_credits(
|
||||
previous: Option<&RateLimitSnapshot>,
|
||||
mut snapshot: RateLimitSnapshot,
|
||||
) -> RateLimitSnapshot {
|
||||
if snapshot.credits.is_none() {
|
||||
snapshot.credits = previous.and_then(|prior| prior.credits.clone());
|
||||
}
|
||||
snapshot
|
||||
}
|
||||
|
||||
@@ -128,7 +128,9 @@ impl Session {
|
||||
task_cancellation_token.child_token(),
|
||||
)
|
||||
.await;
|
||||
session_ctx.clone_session().flush_rollout().await;
|
||||
if let Err(e) = session_ctx.clone_session().flush_rollout().await {
|
||||
tracing::warn!("failed to flush rollout recorder: {e}");
|
||||
}
|
||||
if !task_cancellation_token.is_cancelled() {
|
||||
// Emit completion uniformly from spawn site so all tasks share the same lifecycle.
|
||||
let sess = session_ctx.clone_session();
|
||||
|
||||
@@ -17,6 +17,7 @@ use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::codex_delegate::run_codex_conversation_one_shot;
|
||||
use crate::review_format::format_review_findings_block;
|
||||
use crate::review_format::render_review_output_text;
|
||||
use crate::state::TaskKind;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
|
||||
@@ -24,15 +25,11 @@ use super::SessionTask;
|
||||
use super::SessionTaskContext;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct ReviewTask {
|
||||
append_to_original_thread: bool,
|
||||
}
|
||||
pub(crate) struct ReviewTask;
|
||||
|
||||
impl ReviewTask {
|
||||
pub(crate) fn new(append_to_original_thread: bool) -> Self {
|
||||
Self {
|
||||
append_to_original_thread,
|
||||
}
|
||||
pub(crate) fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,25 +59,13 @@ impl SessionTask for ReviewTask {
|
||||
None => None,
|
||||
};
|
||||
if !cancellation_token.is_cancelled() {
|
||||
exit_review_mode(
|
||||
session.clone_session(),
|
||||
output.clone(),
|
||||
ctx.clone(),
|
||||
self.append_to_original_thread,
|
||||
)
|
||||
.await;
|
||||
exit_review_mode(session.clone_session(), output.clone(), ctx.clone()).await;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
async fn abort(&self, session: Arc<SessionTaskContext>, ctx: Arc<TurnContext>) {
|
||||
exit_review_mode(
|
||||
session.clone_session(),
|
||||
None,
|
||||
ctx,
|
||||
self.append_to_original_thread,
|
||||
)
|
||||
.await;
|
||||
exit_review_mode(session.clone_session(), None, ctx).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,39 +182,57 @@ pub(crate) async fn exit_review_mode(
|
||||
session: Arc<Session>,
|
||||
review_output: Option<ReviewOutputEvent>,
|
||||
ctx: Arc<TurnContext>,
|
||||
append_to_original_thread: bool,
|
||||
) {
|
||||
if append_to_original_thread {
|
||||
let user_message = if let Some(out) = review_output.clone() {
|
||||
let mut findings_str = String::new();
|
||||
let text = out.overall_explanation.trim();
|
||||
if !text.is_empty() {
|
||||
findings_str.push_str(text);
|
||||
}
|
||||
if !out.findings.is_empty() {
|
||||
let block = format_review_findings_block(&out.findings, None);
|
||||
findings_str.push_str(&format!("\n{block}"));
|
||||
}
|
||||
crate::client_common::REVIEW_EXIT_SUCCESS_TMPL.replace("{results}", &findings_str)
|
||||
} else {
|
||||
crate::client_common::REVIEW_EXIT_INTERRUPTED_TMPL.to_string()
|
||||
};
|
||||
const REVIEW_USER_MESSAGE_ID: &str = "review:rollout:user";
|
||||
const REVIEW_ASSISTANT_MESSAGE_ID: &str = "review:rollout:assistant";
|
||||
let (user_message, assistant_message) = if let Some(out) = review_output.clone() {
|
||||
let mut findings_str = String::new();
|
||||
let text = out.overall_explanation.trim();
|
||||
if !text.is_empty() {
|
||||
findings_str.push_str(text);
|
||||
}
|
||||
if !out.findings.is_empty() {
|
||||
let block = format_review_findings_block(&out.findings, None);
|
||||
findings_str.push_str(&format!("\n{block}"));
|
||||
}
|
||||
let rendered =
|
||||
crate::client_common::REVIEW_EXIT_SUCCESS_TMPL.replace("{results}", &findings_str);
|
||||
let assistant_message = render_review_output_text(&out);
|
||||
(rendered, assistant_message)
|
||||
} else {
|
||||
let rendered = crate::client_common::REVIEW_EXIT_INTERRUPTED_TMPL.to_string();
|
||||
let assistant_message =
|
||||
"Review was interrupted. Please re-run /review and wait for it to complete."
|
||||
.to_string();
|
||||
(rendered, assistant_message)
|
||||
};
|
||||
|
||||
session
|
||||
.record_conversation_items(
|
||||
&ctx,
|
||||
&[ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText { text: user_message }],
|
||||
}],
|
||||
)
|
||||
.await;
|
||||
}
|
||||
session
|
||||
.record_conversation_items(
|
||||
&ctx,
|
||||
&[ResponseItem::Message {
|
||||
id: Some(REVIEW_USER_MESSAGE_ID.to_string()),
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText { text: user_message }],
|
||||
}],
|
||||
)
|
||||
.await;
|
||||
session
|
||||
.send_event(
|
||||
ctx.as_ref(),
|
||||
EventMsg::ExitedReviewMode(ExitedReviewModeEvent { review_output }),
|
||||
)
|
||||
.await;
|
||||
session
|
||||
.record_response_item_and_emit_turn_item(
|
||||
ctx.as_ref(),
|
||||
ResponseItem::Message {
|
||||
id: Some(REVIEW_ASSISTANT_MESSAGE_ID.to_string()),
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: assistant_message,
|
||||
}],
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ impl SessionTask for UserShellCommandTask {
|
||||
turn_context.as_ref(),
|
||||
EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
|
||||
call_id: call_id.clone(),
|
||||
process_id: None,
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
command: command.clone(),
|
||||
cwd: cwd.clone(),
|
||||
@@ -139,6 +140,7 @@ impl SessionTask for UserShellCommandTask {
|
||||
turn_context.as_ref(),
|
||||
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id,
|
||||
process_id: None,
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
command: command.clone(),
|
||||
cwd: cwd.clone(),
|
||||
@@ -161,6 +163,7 @@ impl SessionTask for UserShellCommandTask {
|
||||
turn_context.as_ref(),
|
||||
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id: call_id.clone(),
|
||||
process_id: None,
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
command: command.clone(),
|
||||
cwd: cwd.clone(),
|
||||
@@ -205,6 +208,7 @@ impl SessionTask for UserShellCommandTask {
|
||||
turn_context.as_ref(),
|
||||
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id,
|
||||
process_id: None,
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
command,
|
||||
cwd,
|
||||
|
||||
@@ -65,12 +65,14 @@ pub(crate) async fn emit_exec_command_begin(
|
||||
parsed_cmd: &[ParsedCommand],
|
||||
source: ExecCommandSource,
|
||||
interaction_input: Option<String>,
|
||||
process_id: Option<&str>,
|
||||
) {
|
||||
ctx.session
|
||||
.send_event(
|
||||
ctx.turn,
|
||||
EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
|
||||
call_id: ctx.call_id.to_string(),
|
||||
process_id: process_id.map(str::to_owned),
|
||||
turn_id: ctx.turn.sub_id.clone(),
|
||||
command: command.to_vec(),
|
||||
cwd: cwd.to_path_buf(),
|
||||
@@ -100,6 +102,7 @@ pub(crate) enum ToolEmitter {
|
||||
source: ExecCommandSource,
|
||||
interaction_input: Option<String>,
|
||||
parsed_cmd: Vec<ParsedCommand>,
|
||||
process_id: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -132,6 +135,7 @@ impl ToolEmitter {
|
||||
cwd: PathBuf,
|
||||
source: ExecCommandSource,
|
||||
interaction_input: Option<String>,
|
||||
process_id: Option<String>,
|
||||
) -> Self {
|
||||
let parsed_cmd = parse_command(command);
|
||||
Self::UnifiedExec {
|
||||
@@ -140,6 +144,7 @@ impl ToolEmitter {
|
||||
source,
|
||||
interaction_input,
|
||||
parsed_cmd,
|
||||
process_id,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,7 +162,7 @@ impl ToolEmitter {
|
||||
) => {
|
||||
emit_exec_stage(
|
||||
ctx,
|
||||
ExecCommandInput::new(command, cwd.as_path(), parsed_cmd, *source, None),
|
||||
ExecCommandInput::new(command, cwd.as_path(), parsed_cmd, *source, None, None),
|
||||
stage,
|
||||
)
|
||||
.await;
|
||||
@@ -229,6 +234,7 @@ impl ToolEmitter {
|
||||
source,
|
||||
interaction_input,
|
||||
parsed_cmd,
|
||||
process_id,
|
||||
},
|
||||
stage,
|
||||
) => {
|
||||
@@ -240,6 +246,7 @@ impl ToolEmitter {
|
||||
parsed_cmd,
|
||||
*source,
|
||||
interaction_input.as_deref(),
|
||||
process_id.as_deref(),
|
||||
),
|
||||
stage,
|
||||
)
|
||||
@@ -319,6 +326,7 @@ struct ExecCommandInput<'a> {
|
||||
parsed_cmd: &'a [ParsedCommand],
|
||||
source: ExecCommandSource,
|
||||
interaction_input: Option<&'a str>,
|
||||
process_id: Option<&'a str>,
|
||||
}
|
||||
|
||||
impl<'a> ExecCommandInput<'a> {
|
||||
@@ -328,6 +336,7 @@ impl<'a> ExecCommandInput<'a> {
|
||||
parsed_cmd: &'a [ParsedCommand],
|
||||
source: ExecCommandSource,
|
||||
interaction_input: Option<&'a str>,
|
||||
process_id: Option<&'a str>,
|
||||
) -> Self {
|
||||
Self {
|
||||
command,
|
||||
@@ -335,6 +344,7 @@ impl<'a> ExecCommandInput<'a> {
|
||||
parsed_cmd,
|
||||
source,
|
||||
interaction_input,
|
||||
process_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -362,6 +372,7 @@ async fn emit_exec_stage(
|
||||
exec_input.parsed_cmd,
|
||||
exec_input.source,
|
||||
exec_input.interaction_input.map(str::to_owned),
|
||||
exec_input.process_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -402,6 +413,7 @@ async fn emit_exec_end(
|
||||
ctx.turn,
|
||||
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id: ctx.call_id.to_string(),
|
||||
process_id: exec_input.process_id.map(str::to_owned),
|
||||
turn_id: ctx.turn.sub_id.clone(),
|
||||
command: exec_input.command.to_vec(),
|
||||
cwd: exec_input.cwd.to_path_buf(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::apply_patch;
|
||||
use crate::apply_patch::InternalApplyPatchInvocation;
|
||||
@@ -7,7 +8,10 @@ use crate::client_common::tools::FreeformTool;
|
||||
use crate::client_common::tools::FreeformToolFormat;
|
||||
use crate::client_common::tools::ResponsesApiTool;
|
||||
use crate::client_common::tools::ToolSpec;
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::tools::context::SharedTurnDiffTracker;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolOutput;
|
||||
use crate::tools::context::ToolPayload;
|
||||
@@ -164,6 +168,86 @@ pub enum ApplyPatchToolType {
|
||||
Function,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn intercept_apply_patch(
|
||||
command: &[String],
|
||||
cwd: &Path,
|
||||
timeout_ms: Option<u64>,
|
||||
session: &Session,
|
||||
turn: &TurnContext,
|
||||
tracker: Option<&SharedTurnDiffTracker>,
|
||||
call_id: &str,
|
||||
tool_name: &str,
|
||||
) -> Result<Option<ToolOutput>, FunctionCallError> {
|
||||
match codex_apply_patch::maybe_parse_apply_patch_verified(command, cwd) {
|
||||
codex_apply_patch::MaybeApplyPatchVerified::Body(changes) => {
|
||||
session
|
||||
.record_model_warning(
|
||||
format!("apply_patch was requested via {tool_name}. Use the apply_patch tool instead of exec_command."),
|
||||
turn,
|
||||
)
|
||||
.await;
|
||||
match apply_patch::apply_patch(session, turn, call_id, changes).await {
|
||||
InternalApplyPatchInvocation::Output(item) => {
|
||||
let content = item?;
|
||||
Ok(Some(ToolOutput::Function {
|
||||
content,
|
||||
content_items: None,
|
||||
success: Some(true),
|
||||
}))
|
||||
}
|
||||
InternalApplyPatchInvocation::DelegateToExec(apply) => {
|
||||
let emitter = ToolEmitter::apply_patch(
|
||||
convert_apply_patch_to_protocol(&apply.action),
|
||||
!apply.user_explicitly_approved_this_action,
|
||||
);
|
||||
let event_ctx =
|
||||
ToolEventCtx::new(session, turn, call_id, tracker.as_ref().copied());
|
||||
emitter.begin(event_ctx).await;
|
||||
|
||||
let req = ApplyPatchRequest {
|
||||
patch: apply.action.patch.clone(),
|
||||
cwd: apply.action.cwd.clone(),
|
||||
timeout_ms,
|
||||
user_explicitly_approved: apply.user_explicitly_approved_this_action,
|
||||
codex_exe: turn.codex_linux_sandbox_exe.clone(),
|
||||
};
|
||||
|
||||
let mut orchestrator = ToolOrchestrator::new();
|
||||
let mut runtime = ApplyPatchRuntime::new();
|
||||
let tool_ctx = ToolCtx {
|
||||
session,
|
||||
turn,
|
||||
call_id: call_id.to_string(),
|
||||
tool_name: tool_name.to_string(),
|
||||
};
|
||||
let out = orchestrator
|
||||
.run(&mut runtime, &req, &tool_ctx, turn, turn.approval_policy)
|
||||
.await;
|
||||
let event_ctx =
|
||||
ToolEventCtx::new(session, turn, call_id, tracker.as_ref().copied());
|
||||
let content = emitter.finish(event_ctx, out).await?;
|
||||
Ok(Some(ToolOutput::Function {
|
||||
content,
|
||||
content_items: None,
|
||||
success: Some(true),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
codex_apply_patch::MaybeApplyPatchVerified::CorrectnessError(parse_error) => {
|
||||
Err(FunctionCallError::RespondToModel(format!(
|
||||
"apply_patch verification failed: {parse_error}"
|
||||
)))
|
||||
}
|
||||
codex_apply_patch::MaybeApplyPatchVerified::ShellParseError(error) => {
|
||||
tracing::trace!("Failed to parse apply_patch input, {error:?}");
|
||||
Ok(None)
|
||||
}
|
||||
codex_apply_patch::MaybeApplyPatchVerified::NotApplyPatch => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a custom tool that can be used to edit files. Well-suited for GPT-5 models
|
||||
/// https://platform.openai.com/docs/guides/function-calling#custom-tools
|
||||
pub(crate) fn create_apply_patch_freeform_tool() -> ToolSpec {
|
||||
|
||||
@@ -3,9 +3,6 @@ use codex_protocol::models::ShellCommandToolCallParams;
|
||||
use codex_protocol::models::ShellToolCallParams;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::apply_patch;
|
||||
use crate::apply_patch::InternalApplyPatchInvocation;
|
||||
use crate::apply_patch::convert_apply_patch_to_protocol;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::exec::ExecParams;
|
||||
use crate::exec_env::create_env;
|
||||
@@ -19,11 +16,10 @@ use crate::tools::context::ToolOutput;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::events::ToolEmitter;
|
||||
use crate::tools::events::ToolEventCtx;
|
||||
use crate::tools::handlers::apply_patch::intercept_apply_patch;
|
||||
use crate::tools::orchestrator::ToolOrchestrator;
|
||||
use crate::tools::registry::ToolHandler;
|
||||
use crate::tools::registry::ToolKind;
|
||||
use crate::tools::runtimes::apply_patch::ApplyPatchRequest;
|
||||
use crate::tools::runtimes::apply_patch::ApplyPatchRuntime;
|
||||
use crate::tools::runtimes::shell::ShellRequest;
|
||||
use crate::tools::runtimes::shell::ShellRuntime;
|
||||
use crate::tools::sandboxing::ToolCtx;
|
||||
@@ -210,81 +206,19 @@ impl ShellHandler {
|
||||
}
|
||||
|
||||
// Intercept apply_patch if present.
|
||||
match codex_apply_patch::maybe_parse_apply_patch_verified(
|
||||
if let Some(output) = intercept_apply_patch(
|
||||
&exec_params.command,
|
||||
&exec_params.cwd,
|
||||
) {
|
||||
codex_apply_patch::MaybeApplyPatchVerified::Body(changes) => {
|
||||
match apply_patch::apply_patch(session.as_ref(), turn.as_ref(), &call_id, changes)
|
||||
.await
|
||||
{
|
||||
InternalApplyPatchInvocation::Output(item) => {
|
||||
// Programmatic apply_patch path; return its result.
|
||||
let content = item?;
|
||||
return Ok(ToolOutput::Function {
|
||||
content,
|
||||
content_items: None,
|
||||
success: Some(true),
|
||||
});
|
||||
}
|
||||
InternalApplyPatchInvocation::DelegateToExec(apply) => {
|
||||
let emitter = ToolEmitter::apply_patch(
|
||||
convert_apply_patch_to_protocol(&apply.action),
|
||||
!apply.user_explicitly_approved_this_action,
|
||||
);
|
||||
let event_ctx = ToolEventCtx::new(
|
||||
session.as_ref(),
|
||||
turn.as_ref(),
|
||||
&call_id,
|
||||
Some(&tracker),
|
||||
);
|
||||
emitter.begin(event_ctx).await;
|
||||
|
||||
let req = ApplyPatchRequest {
|
||||
patch: apply.action.patch.clone(),
|
||||
cwd: apply.action.cwd.clone(),
|
||||
timeout_ms: exec_params.expiration.timeout_ms(),
|
||||
user_explicitly_approved: apply.user_explicitly_approved_this_action,
|
||||
codex_exe: turn.codex_linux_sandbox_exe.clone(),
|
||||
};
|
||||
let mut orchestrator = ToolOrchestrator::new();
|
||||
let mut runtime = ApplyPatchRuntime::new();
|
||||
let tool_ctx = ToolCtx {
|
||||
session: session.as_ref(),
|
||||
turn: turn.as_ref(),
|
||||
call_id: call_id.clone(),
|
||||
tool_name: tool_name.to_string(),
|
||||
};
|
||||
let out = orchestrator
|
||||
.run(&mut runtime, &req, &tool_ctx, &turn, turn.approval_policy)
|
||||
.await;
|
||||
let event_ctx = ToolEventCtx::new(
|
||||
session.as_ref(),
|
||||
turn.as_ref(),
|
||||
&call_id,
|
||||
Some(&tracker),
|
||||
);
|
||||
let content = emitter.finish(event_ctx, out).await?;
|
||||
return Ok(ToolOutput::Function {
|
||||
content,
|
||||
content_items: None,
|
||||
success: Some(true),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
codex_apply_patch::MaybeApplyPatchVerified::CorrectnessError(parse_error) => {
|
||||
return Err(FunctionCallError::RespondToModel(format!(
|
||||
"apply_patch verification failed: {parse_error}"
|
||||
)));
|
||||
}
|
||||
codex_apply_patch::MaybeApplyPatchVerified::ShellParseError(error) => {
|
||||
tracing::trace!("Failed to parse shell command, {error:?}");
|
||||
// Fall through to regular shell execution.
|
||||
}
|
||||
codex_apply_patch::MaybeApplyPatchVerified::NotApplyPatch => {
|
||||
// Fall through to regular shell execution.
|
||||
}
|
||||
exec_params.expiration.timeout_ms(),
|
||||
session.as_ref(),
|
||||
turn.as_ref(),
|
||||
Some(&tracker),
|
||||
&call_id,
|
||||
tool_name,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(output);
|
||||
}
|
||||
|
||||
let source = ExecCommandSource::Agent;
|
||||
@@ -297,6 +231,15 @@ impl ShellHandler {
|
||||
let event_ctx = ToolEventCtx::new(session.as_ref(), turn.as_ref(), &call_id, None);
|
||||
emitter.begin(event_ctx).await;
|
||||
|
||||
let approval_requirement = create_approval_requirement_for_command(
|
||||
&turn.exec_policy,
|
||||
&exec_params.command,
|
||||
turn.approval_policy,
|
||||
&turn.sandbox_policy,
|
||||
SandboxPermissions::from(exec_params.with_escalated_permissions.unwrap_or(false)),
|
||||
)
|
||||
.await;
|
||||
|
||||
let req = ShellRequest {
|
||||
command: exec_params.command.clone(),
|
||||
cwd: exec_params.cwd.clone(),
|
||||
@@ -304,13 +247,7 @@ impl ShellHandler {
|
||||
env: exec_params.env.clone(),
|
||||
with_escalated_permissions: exec_params.with_escalated_permissions,
|
||||
justification: exec_params.justification.clone(),
|
||||
approval_requirement: create_approval_requirement_for_command(
|
||||
&turn.exec_policy,
|
||||
&exec_params.command,
|
||||
turn.approval_policy,
|
||||
&turn.sandbox_policy,
|
||||
SandboxPermissions::from(exec_params.with_escalated_permissions.unwrap_or(false)),
|
||||
),
|
||||
approval_requirement,
|
||||
};
|
||||
let mut orchestrator = ToolOrchestrator::new();
|
||||
let mut runtime = ShellRuntime::new();
|
||||
|
||||
@@ -6,6 +6,7 @@ use crate::protocol::EventMsg;
|
||||
use crate::protocol::ExecCommandOutputDeltaEvent;
|
||||
use crate::protocol::ExecCommandSource;
|
||||
use crate::protocol::ExecOutputStream;
|
||||
use crate::shell::default_user_shell;
|
||||
use crate::shell::get_shell_by_model_provided_path;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolOutput;
|
||||
@@ -13,6 +14,7 @@ use crate::tools::context::ToolPayload;
|
||||
use crate::tools::events::ToolEmitter;
|
||||
use crate::tools::events::ToolEventCtx;
|
||||
use crate::tools::events::ToolEventStage;
|
||||
use crate::tools::handlers::apply_patch::intercept_apply_patch;
|
||||
use crate::tools::registry::ToolHandler;
|
||||
use crate::tools::registry::ToolKind;
|
||||
use crate::unified_exec::ExecCommandRequest;
|
||||
@@ -30,8 +32,8 @@ struct ExecCommandArgs {
|
||||
cmd: String,
|
||||
#[serde(default)]
|
||||
workdir: Option<String>,
|
||||
#[serde(default = "default_shell")]
|
||||
shell: String,
|
||||
#[serde(default)]
|
||||
shell: Option<String>,
|
||||
#[serde(default = "default_login")]
|
||||
login: bool,
|
||||
#[serde(default = "default_exec_yield_time_ms")]
|
||||
@@ -46,6 +48,7 @@ struct ExecCommandArgs {
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WriteStdinArgs {
|
||||
// The model is trained on `session_id`.
|
||||
session_id: i32,
|
||||
#[serde(default)]
|
||||
chars: String,
|
||||
@@ -63,10 +66,6 @@ fn default_write_stdin_yield_time_ms() -> u64 {
|
||||
250
|
||||
}
|
||||
|
||||
fn default_shell() -> String {
|
||||
"/bin/bash".to_string()
|
||||
}
|
||||
|
||||
fn default_login() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -102,6 +101,7 @@ impl ToolHandler for UnifiedExecHandler {
|
||||
let ToolInvocation {
|
||||
session,
|
||||
turn,
|
||||
tracker,
|
||||
call_id,
|
||||
tool_name,
|
||||
payload,
|
||||
@@ -128,6 +128,7 @@ impl ToolHandler for UnifiedExecHandler {
|
||||
"failed to parse exec_command arguments: {err:?}"
|
||||
))
|
||||
})?;
|
||||
let process_id = manager.allocate_process_id().await;
|
||||
|
||||
let command = get_command(&args);
|
||||
let ExecCommandArgs {
|
||||
@@ -151,12 +152,26 @@ impl ToolHandler for UnifiedExecHandler {
|
||||
)));
|
||||
}
|
||||
|
||||
let workdir = workdir
|
||||
.as_deref()
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from);
|
||||
let workdir = workdir.filter(|value| !value.is_empty());
|
||||
|
||||
let workdir = workdir.map(|dir| context.turn.resolve_path(Some(dir)));
|
||||
let cwd = workdir.clone().unwrap_or_else(|| context.turn.cwd.clone());
|
||||
|
||||
if let Some(output) = intercept_apply_patch(
|
||||
&command,
|
||||
&cwd,
|
||||
Some(yield_time_ms),
|
||||
context.session.as_ref(),
|
||||
context.turn.as_ref(),
|
||||
Some(&tracker),
|
||||
&context.call_id,
|
||||
tool_name.as_str(),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(output);
|
||||
}
|
||||
|
||||
let event_ctx = ToolEventCtx::new(
|
||||
context.session.as_ref(),
|
||||
context.turn.as_ref(),
|
||||
@@ -168,6 +183,7 @@ impl ToolHandler for UnifiedExecHandler {
|
||||
cwd.clone(),
|
||||
ExecCommandSource::UnifiedExecStartup,
|
||||
None,
|
||||
Some(process_id.clone()),
|
||||
);
|
||||
emitter.emit(event_ctx, ToolEventStage::Begin).await;
|
||||
|
||||
@@ -175,6 +191,7 @@ impl ToolHandler for UnifiedExecHandler {
|
||||
.exec_command(
|
||||
ExecCommandRequest {
|
||||
command,
|
||||
process_id,
|
||||
yield_time_ms,
|
||||
max_output_tokens,
|
||||
workdir,
|
||||
@@ -197,7 +214,7 @@ impl ToolHandler for UnifiedExecHandler {
|
||||
manager
|
||||
.write_stdin(WriteStdinRequest {
|
||||
call_id: &call_id,
|
||||
session_id: args.session_id,
|
||||
process_id: &args.session_id.to_string(),
|
||||
input: &args.chars,
|
||||
yield_time_ms: args.yield_time_ms,
|
||||
max_output_tokens: args.max_output_tokens,
|
||||
@@ -237,7 +254,12 @@ impl ToolHandler for UnifiedExecHandler {
|
||||
}
|
||||
|
||||
fn get_command(args: &ExecCommandArgs) -> Vec<String> {
|
||||
let shell = get_shell_by_model_provided_path(&PathBuf::from(args.shell.clone()));
|
||||
let shell = if let Some(shell_str) = &args.shell {
|
||||
get_shell_by_model_provided_path(&PathBuf::from(shell_str))
|
||||
} else {
|
||||
default_user_shell()
|
||||
};
|
||||
|
||||
shell.derive_exec_args(&args.cmd, args.login)
|
||||
}
|
||||
|
||||
@@ -255,8 +277,9 @@ fn format_response(response: &UnifiedExecResponse) -> String {
|
||||
sections.push(format!("Process exited with code {exit_code}"));
|
||||
}
|
||||
|
||||
if let Some(session_id) = response.session_id {
|
||||
sections.push(format!("Process running with session ID {session_id}"));
|
||||
if let Some(process_id) = &response.process_id {
|
||||
// Training still uses "session ID".
|
||||
sections.push(format!("Process running with session ID {process_id}"));
|
||||
}
|
||||
|
||||
if let Some(original_token_count) = response.original_token_count {
|
||||
@@ -268,3 +291,65 @@ fn format_response(response: &UnifiedExecResponse) -> String {
|
||||
|
||||
sections.join("\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_get_command_uses_default_shell_when_unspecified() {
|
||||
let json = r#"{"cmd": "echo hello"}"#;
|
||||
|
||||
let args: ExecCommandArgs =
|
||||
serde_json::from_str(json).expect("deserialize ExecCommandArgs");
|
||||
|
||||
assert!(args.shell.is_none());
|
||||
|
||||
let command = get_command(&args);
|
||||
|
||||
assert_eq!(command.len(), 3);
|
||||
assert_eq!(command[2], "echo hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_command_respects_explicit_bash_shell() {
|
||||
let json = r#"{"cmd": "echo hello", "shell": "/bin/bash"}"#;
|
||||
|
||||
let args: ExecCommandArgs =
|
||||
serde_json::from_str(json).expect("deserialize ExecCommandArgs");
|
||||
|
||||
assert_eq!(args.shell.as_deref(), Some("/bin/bash"));
|
||||
|
||||
let command = get_command(&args);
|
||||
|
||||
assert_eq!(command[2], "echo hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_command_respects_explicit_powershell_shell() {
|
||||
let json = r#"{"cmd": "echo hello", "shell": "powershell"}"#;
|
||||
|
||||
let args: ExecCommandArgs =
|
||||
serde_json::from_str(json).expect("deserialize ExecCommandArgs");
|
||||
|
||||
assert_eq!(args.shell.as_deref(), Some("powershell"));
|
||||
|
||||
let command = get_command(&args);
|
||||
|
||||
assert_eq!(command[2], "echo hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_command_respects_explicit_cmd_shell() {
|
||||
let json = r#"{"cmd": "echo hello", "shell": "cmd"}"#;
|
||||
|
||||
let args: ExecCommandArgs =
|
||||
serde_json::from_str(json).expect("deserialize ExecCommandArgs");
|
||||
|
||||
assert_eq!(args.shell.as_deref(), Some("cmd"));
|
||||
|
||||
let command = get_command(&args);
|
||||
|
||||
assert_eq!(command[2], "echo hello");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,9 @@ use thiserror::Error;
|
||||
pub(crate) enum UnifiedExecError {
|
||||
#[error("Failed to create unified exec session: {message}")]
|
||||
CreateSession { message: String },
|
||||
#[error("Unknown session id {session_id}")]
|
||||
UnknownSessionId { session_id: i32 },
|
||||
// Called "session" in the model's training.
|
||||
#[error("Unknown session id {process_id}")]
|
||||
UnknownSessionId { process_id: String },
|
||||
#[error("failed to write to stdin")]
|
||||
WriteToStdin,
|
||||
#[error("missing command line for unified exec request")]
|
||||
|
||||
@@ -22,9 +22,9 @@
|
||||
//! - `session_manager.rs`: orchestration (approvals, sandboxing, reuse) and request handling.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicI32;
|
||||
use std::time::Duration;
|
||||
|
||||
use rand::Rng;
|
||||
@@ -48,6 +48,9 @@ pub(crate) const UNIFIED_EXEC_OUTPUT_MAX_BYTES: usize = 1024 * 1024; // 1 MiB
|
||||
pub(crate) const UNIFIED_EXEC_OUTPUT_MAX_TOKENS: usize = UNIFIED_EXEC_OUTPUT_MAX_BYTES / 4;
|
||||
pub(crate) const MAX_UNIFIED_EXEC_SESSIONS: usize = 64;
|
||||
|
||||
// Send a warning message to the models when it reaches this number of sessions.
|
||||
pub(crate) const WARNING_UNIFIED_EXEC_SESSIONS: usize = 60;
|
||||
|
||||
pub(crate) struct UnifiedExecContext {
|
||||
pub session: Arc<Session>,
|
||||
pub turn: Arc<TurnContext>,
|
||||
@@ -67,6 +70,7 @@ impl UnifiedExecContext {
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ExecCommandRequest {
|
||||
pub command: Vec<String>,
|
||||
pub process_id: String,
|
||||
pub yield_time_ms: u64,
|
||||
pub max_output_tokens: Option<usize>,
|
||||
pub workdir: Option<PathBuf>,
|
||||
@@ -77,7 +81,7 @@ pub(crate) struct ExecCommandRequest {
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct WriteStdinRequest<'a> {
|
||||
pub call_id: &'a str,
|
||||
pub session_id: i32,
|
||||
pub process_id: &'a str,
|
||||
pub input: &'a str,
|
||||
pub yield_time_ms: u64,
|
||||
pub max_output_tokens: Option<usize>,
|
||||
@@ -89,7 +93,7 @@ pub(crate) struct UnifiedExecResponse {
|
||||
pub chunk_id: String,
|
||||
pub wall_time: Duration,
|
||||
pub output: String,
|
||||
pub session_id: Option<i32>,
|
||||
pub process_id: Option<String>,
|
||||
pub exit_code: Option<i32>,
|
||||
pub original_token_count: Option<usize>,
|
||||
pub session_command: Option<Vec<String>>,
|
||||
@@ -97,15 +101,34 @@ pub(crate) struct UnifiedExecResponse {
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct UnifiedExecSessionManager {
|
||||
next_session_id: AtomicI32,
|
||||
sessions: Mutex<HashMap<i32, SessionEntry>>,
|
||||
session_store: Mutex<SessionStore>,
|
||||
}
|
||||
|
||||
// Required for mutex sharing.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct SessionStore {
|
||||
sessions: HashMap<String, SessionEntry>,
|
||||
reserved_sessions_id: HashSet<String>,
|
||||
}
|
||||
|
||||
impl SessionStore {
|
||||
fn remove(&mut self, session_id: &str) -> Option<SessionEntry> {
|
||||
self.reserved_sessions_id.remove(session_id);
|
||||
self.sessions.remove(session_id)
|
||||
}
|
||||
|
||||
pub(crate) fn clear(&mut self) {
|
||||
self.reserved_sessions_id.clear();
|
||||
self.sessions.clear();
|
||||
}
|
||||
}
|
||||
|
||||
struct SessionEntry {
|
||||
session: session::UnifiedExecSession,
|
||||
session: UnifiedExecSession,
|
||||
session_ref: Arc<Session>,
|
||||
turn_ref: Arc<TurnContext>,
|
||||
call_id: String,
|
||||
process_id: String,
|
||||
command: Vec<String>,
|
||||
cwd: PathBuf,
|
||||
started_at: tokio::time::Instant,
|
||||
@@ -159,6 +182,11 @@ mod tests {
|
||||
) -> Result<UnifiedExecResponse, UnifiedExecError> {
|
||||
let context =
|
||||
UnifiedExecContext::new(Arc::clone(session), Arc::clone(turn), "call".to_string());
|
||||
let process_id = session
|
||||
.services
|
||||
.unified_exec_manager
|
||||
.allocate_process_id()
|
||||
.await;
|
||||
|
||||
session
|
||||
.services
|
||||
@@ -166,6 +194,7 @@ mod tests {
|
||||
.exec_command(
|
||||
ExecCommandRequest {
|
||||
command: vec!["bash".to_string(), "-lc".to_string(), cmd.to_string()],
|
||||
process_id,
|
||||
yield_time_ms,
|
||||
max_output_tokens: None,
|
||||
workdir: None,
|
||||
@@ -179,7 +208,7 @@ mod tests {
|
||||
|
||||
async fn write_stdin(
|
||||
session: &Arc<Session>,
|
||||
session_id: i32,
|
||||
process_id: &str,
|
||||
input: &str,
|
||||
yield_time_ms: u64,
|
||||
) -> Result<UnifiedExecResponse, UnifiedExecError> {
|
||||
@@ -188,7 +217,7 @@ mod tests {
|
||||
.unified_exec_manager
|
||||
.write_stdin(WriteStdinRequest {
|
||||
call_id: "write-stdin",
|
||||
session_id,
|
||||
process_id,
|
||||
input,
|
||||
yield_time_ms,
|
||||
max_output_tokens: None,
|
||||
@@ -221,11 +250,15 @@ mod tests {
|
||||
let (session, turn) = test_session_and_turn();
|
||||
|
||||
let open_shell = exec_command(&session, &turn, "bash -i", 2_500).await?;
|
||||
let session_id = open_shell.session_id.expect("expected session_id");
|
||||
let process_id = open_shell
|
||||
.process_id
|
||||
.as_ref()
|
||||
.expect("expected process_id")
|
||||
.as_str();
|
||||
|
||||
write_stdin(
|
||||
&session,
|
||||
session_id,
|
||||
process_id,
|
||||
"export CODEX_INTERACTIVE_SHELL_VAR=codex\n",
|
||||
2_500,
|
||||
)
|
||||
@@ -233,7 +266,7 @@ mod tests {
|
||||
|
||||
let out_2 = write_stdin(
|
||||
&session,
|
||||
session_id,
|
||||
process_id,
|
||||
"echo $CODEX_INTERACTIVE_SHELL_VAR\n",
|
||||
2_500,
|
||||
)
|
||||
@@ -253,11 +286,15 @@ mod tests {
|
||||
let (session, turn) = test_session_and_turn();
|
||||
|
||||
let shell_a = exec_command(&session, &turn, "bash -i", 2_500).await?;
|
||||
let session_a = shell_a.session_id.expect("expected session id");
|
||||
let session_a = shell_a
|
||||
.process_id
|
||||
.as_ref()
|
||||
.expect("expected process id")
|
||||
.clone();
|
||||
|
||||
write_stdin(
|
||||
&session,
|
||||
session_a,
|
||||
session_a.as_str(),
|
||||
"export CODEX_INTERACTIVE_SHELL_VAR=codex\n",
|
||||
2_500,
|
||||
)
|
||||
@@ -265,9 +302,10 @@ mod tests {
|
||||
|
||||
let out_2 =
|
||||
exec_command(&session, &turn, "echo $CODEX_INTERACTIVE_SHELL_VAR", 2_500).await?;
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
assert!(
|
||||
out_2.session_id.is_none(),
|
||||
"short command should not retain a session"
|
||||
out_2.process_id.is_none(),
|
||||
"short command should not report a process id if it exits quickly"
|
||||
);
|
||||
assert!(
|
||||
!out_2.output.contains("codex"),
|
||||
@@ -276,7 +314,11 @@ mod tests {
|
||||
|
||||
let out_3 = write_stdin(
|
||||
&session,
|
||||
session_a,
|
||||
shell_a
|
||||
.process_id
|
||||
.as_ref()
|
||||
.expect("expected process id")
|
||||
.as_str(),
|
||||
"echo $CODEX_INTERACTIVE_SHELL_VAR\n",
|
||||
2_500,
|
||||
)
|
||||
@@ -296,11 +338,15 @@ mod tests {
|
||||
let (session, turn) = test_session_and_turn();
|
||||
|
||||
let open_shell = exec_command(&session, &turn, "bash -i", 2_500).await?;
|
||||
let session_id = open_shell.session_id.expect("expected session id");
|
||||
let process_id = open_shell
|
||||
.process_id
|
||||
.as_ref()
|
||||
.expect("expected process id")
|
||||
.as_str();
|
||||
|
||||
write_stdin(
|
||||
&session,
|
||||
session_id,
|
||||
process_id,
|
||||
"export CODEX_INTERACTIVE_SHELL_VAR=codex\n",
|
||||
2_500,
|
||||
)
|
||||
@@ -308,7 +354,7 @@ mod tests {
|
||||
|
||||
let out_2 = write_stdin(
|
||||
&session,
|
||||
session_id,
|
||||
process_id,
|
||||
"sleep 5 && echo $CODEX_INTERACTIVE_SHELL_VAR\n",
|
||||
10,
|
||||
)
|
||||
@@ -320,7 +366,7 @@ mod tests {
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(7)).await;
|
||||
|
||||
let out_3 = write_stdin(&session, session_id, "", 100).await?;
|
||||
let out_3 = write_stdin(&session, process_id, "", 100).await?;
|
||||
|
||||
assert!(
|
||||
out_3.output.contains("codex"),
|
||||
@@ -337,7 +383,7 @@ mod tests {
|
||||
|
||||
let result = exec_command(&session, &turn, "echo codex", 120_000).await?;
|
||||
|
||||
assert!(result.session_id.is_none());
|
||||
assert!(result.process_id.is_some());
|
||||
assert!(result.output.contains("codex"));
|
||||
|
||||
Ok(())
|
||||
@@ -350,8 +396,8 @@ mod tests {
|
||||
let result = exec_command(&session, &turn, "echo codex", 2_500).await?;
|
||||
|
||||
assert!(
|
||||
result.session_id.is_none(),
|
||||
"completed command should not retain session"
|
||||
result.process_id.is_some(),
|
||||
"completed command should report a process id"
|
||||
);
|
||||
assert!(result.output.contains("codex"));
|
||||
|
||||
@@ -359,9 +405,10 @@ mod tests {
|
||||
session
|
||||
.services
|
||||
.unified_exec_manager
|
||||
.sessions
|
||||
.session_store
|
||||
.lock()
|
||||
.await
|
||||
.sessions
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
@@ -375,31 +422,36 @@ mod tests {
|
||||
let (session, turn) = test_session_and_turn();
|
||||
|
||||
let open_shell = exec_command(&session, &turn, "bash -i", 2_500).await?;
|
||||
let session_id = open_shell.session_id.expect("expected session id");
|
||||
let process_id = open_shell
|
||||
.process_id
|
||||
.as_ref()
|
||||
.expect("expected process id")
|
||||
.as_str();
|
||||
|
||||
write_stdin(&session, session_id, "exit\n", 2_500).await?;
|
||||
write_stdin(&session, process_id, "exit\n", 2_500).await?;
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
|
||||
let err = write_stdin(&session, session_id, "", 100)
|
||||
let err = write_stdin(&session, process_id, "", 100)
|
||||
.await
|
||||
.expect_err("expected unknown session error");
|
||||
|
||||
match err {
|
||||
UnifiedExecError::UnknownSessionId { session_id: err_id } => {
|
||||
assert_eq!(err_id, session_id);
|
||||
UnifiedExecError::UnknownSessionId { process_id: err_id } => {
|
||||
assert_eq!(err_id, process_id, "process id should match request");
|
||||
}
|
||||
other => panic!("expected UnknownSessionId, got {other:?}"),
|
||||
}
|
||||
|
||||
assert!(
|
||||
!session
|
||||
session
|
||||
.services
|
||||
.unified_exec_manager
|
||||
.sessions
|
||||
.session_store
|
||||
.lock()
|
||||
.await
|
||||
.contains_key(&session_id)
|
||||
.sessions
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use rand::Rng;
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::Notify;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::Duration;
|
||||
@@ -36,10 +36,12 @@ use crate::truncate::formatted_truncate_text;
|
||||
use super::ExecCommandRequest;
|
||||
use super::MAX_UNIFIED_EXEC_SESSIONS;
|
||||
use super::SessionEntry;
|
||||
use super::SessionStore;
|
||||
use super::UnifiedExecContext;
|
||||
use super::UnifiedExecError;
|
||||
use super::UnifiedExecResponse;
|
||||
use super::UnifiedExecSessionManager;
|
||||
use super::WARNING_UNIFIED_EXEC_SESSIONS;
|
||||
use super::WriteStdinRequest;
|
||||
use super::clamp_yield_time;
|
||||
use super::generate_chunk_id;
|
||||
@@ -75,9 +77,39 @@ struct PreparedSessionHandles {
|
||||
turn_ref: Arc<TurnContext>,
|
||||
command: Vec<String>,
|
||||
cwd: PathBuf,
|
||||
process_id: String,
|
||||
}
|
||||
|
||||
impl UnifiedExecSessionManager {
|
||||
pub(crate) async fn allocate_process_id(&self) -> String {
|
||||
loop {
|
||||
let mut store = self.session_store.lock().await;
|
||||
|
||||
let process_id = if !cfg!(test) && !cfg!(feature = "deterministic_process_ids") {
|
||||
// production mode → random
|
||||
rand::rng().random_range(1_000..100_000).to_string()
|
||||
} else {
|
||||
// test or deterministic mode
|
||||
let next = store
|
||||
.reserved_sessions_id
|
||||
.iter()
|
||||
.filter_map(|s| s.parse::<i32>().ok())
|
||||
.max()
|
||||
.map(|m| std::cmp::max(m, 999) + 1)
|
||||
.unwrap_or(1000);
|
||||
|
||||
next.to_string()
|
||||
};
|
||||
|
||||
if store.reserved_sessions_id.contains(&process_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
store.reserved_sessions_id.insert(process_id.clone());
|
||||
return process_id;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn exec_command(
|
||||
&self,
|
||||
request: ExecCommandRequest,
|
||||
@@ -122,14 +154,20 @@ impl UnifiedExecSessionManager {
|
||||
let has_exited = session.has_exited();
|
||||
let exit_code = session.exit_code();
|
||||
let chunk_id = generate_chunk_id();
|
||||
let session_id = if has_exited {
|
||||
let process_id = if has_exited {
|
||||
None
|
||||
} else {
|
||||
// Only store session if not exited.
|
||||
let stored_id = self
|
||||
.store_session(session, context, &request.command, cwd.clone(), start)
|
||||
.await;
|
||||
Some(stored_id)
|
||||
self.store_session(
|
||||
session,
|
||||
context,
|
||||
&request.command,
|
||||
cwd.clone(),
|
||||
start,
|
||||
request.process_id.clone(),
|
||||
)
|
||||
.await;
|
||||
Some(request.process_id.clone())
|
||||
};
|
||||
let original_token_count = approx_token_count(&text);
|
||||
|
||||
@@ -138,18 +176,18 @@ impl UnifiedExecSessionManager {
|
||||
chunk_id,
|
||||
wall_time,
|
||||
output,
|
||||
session_id,
|
||||
process_id: process_id.clone(),
|
||||
exit_code,
|
||||
original_token_count: Some(original_token_count),
|
||||
session_command: Some(request.command.clone()),
|
||||
};
|
||||
|
||||
if response.session_id.is_some() {
|
||||
if !has_exited {
|
||||
Self::emit_waiting_status(&context.session, &context.turn, &request.command).await;
|
||||
}
|
||||
|
||||
// If the command completed during this call, emit an ExecCommandEnd via the emitter.
|
||||
if response.session_id.is_none() {
|
||||
if has_exited {
|
||||
let exit = response.exit_code.unwrap_or(-1);
|
||||
Self::emit_exec_end_from_context(
|
||||
context,
|
||||
@@ -158,6 +196,9 @@ impl UnifiedExecSessionManager {
|
||||
response.output.clone(),
|
||||
exit,
|
||||
response.wall_time,
|
||||
// We always emit the process ID in order to keep consistency between the Begin
|
||||
// event and the End event.
|
||||
Some(request.process_id),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -169,7 +210,7 @@ impl UnifiedExecSessionManager {
|
||||
&self,
|
||||
request: WriteStdinRequest<'_>,
|
||||
) -> Result<UnifiedExecResponse, UnifiedExecError> {
|
||||
let session_id = request.session_id;
|
||||
let process_id = request.process_id.to_string();
|
||||
|
||||
let PreparedSessionHandles {
|
||||
writer_tx,
|
||||
@@ -180,13 +221,15 @@ impl UnifiedExecSessionManager {
|
||||
turn_ref,
|
||||
command: session_command,
|
||||
cwd: session_cwd,
|
||||
} = self.prepare_session_handles(session_id).await?;
|
||||
process_id,
|
||||
} = self.prepare_session_handles(process_id.as_str()).await?;
|
||||
|
||||
let interaction_emitter = ToolEmitter::unified_exec(
|
||||
&session_command,
|
||||
session_cwd.clone(),
|
||||
ExecCommandSource::UnifiedExecInteraction,
|
||||
(!request.input.is_empty()).then(|| request.input.to_string()),
|
||||
Some(process_id.clone()),
|
||||
);
|
||||
let make_event_ctx = || {
|
||||
ToolEventCtx::new(
|
||||
@@ -233,17 +276,21 @@ impl UnifiedExecSessionManager {
|
||||
let original_token_count = approx_token_count(&text);
|
||||
let chunk_id = generate_chunk_id();
|
||||
|
||||
let status = self.refresh_session_state(session_id).await;
|
||||
let (session_id, exit_code, completion_entry, event_call_id) = match status {
|
||||
SessionStatus::Alive { exit_code, call_id } => {
|
||||
(Some(session_id), exit_code, None, call_id)
|
||||
}
|
||||
let status = self.refresh_session_state(process_id.as_str()).await;
|
||||
let (process_id, exit_code, completion_entry, event_call_id) = match status {
|
||||
SessionStatus::Alive {
|
||||
exit_code,
|
||||
call_id,
|
||||
process_id,
|
||||
} => (Some(process_id), exit_code, None, call_id),
|
||||
SessionStatus::Exited { exit_code, entry } => {
|
||||
let call_id = entry.call_id.clone();
|
||||
(None, exit_code, Some(*entry), call_id)
|
||||
}
|
||||
SessionStatus::Unknown => {
|
||||
return Err(UnifiedExecError::UnknownSessionId { session_id });
|
||||
return Err(UnifiedExecError::UnknownSessionId {
|
||||
process_id: request.process_id.to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -252,7 +299,7 @@ impl UnifiedExecSessionManager {
|
||||
chunk_id,
|
||||
wall_time,
|
||||
output,
|
||||
session_id,
|
||||
process_id,
|
||||
exit_code,
|
||||
original_token_count: Some(original_token_count),
|
||||
session_command: Some(session_command.clone()),
|
||||
@@ -273,7 +320,7 @@ impl UnifiedExecSessionManager {
|
||||
)
|
||||
.await;
|
||||
|
||||
if response.session_id.is_some() {
|
||||
if response.process_id.is_some() {
|
||||
Self::emit_waiting_status(&session_ref, &turn_ref, &session_command).await;
|
||||
}
|
||||
|
||||
@@ -286,16 +333,17 @@ impl UnifiedExecSessionManager {
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn refresh_session_state(&self, session_id: i32) -> SessionStatus {
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
let Some(entry) = sessions.get(&session_id) else {
|
||||
async fn refresh_session_state(&self, process_id: &str) -> SessionStatus {
|
||||
let mut store = self.session_store.lock().await;
|
||||
let Some(entry) = store.sessions.get(process_id) else {
|
||||
return SessionStatus::Unknown;
|
||||
};
|
||||
|
||||
let exit_code = entry.session.exit_code();
|
||||
let process_id = entry.process_id.clone();
|
||||
|
||||
if entry.session.has_exited() {
|
||||
let Some(entry) = sessions.remove(&session_id) else {
|
||||
let Some(entry) = store.remove(&process_id) else {
|
||||
return SessionStatus::Unknown;
|
||||
};
|
||||
SessionStatus::Exited {
|
||||
@@ -306,18 +354,23 @@ impl UnifiedExecSessionManager {
|
||||
SessionStatus::Alive {
|
||||
exit_code,
|
||||
call_id: entry.call_id.clone(),
|
||||
process_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn prepare_session_handles(
|
||||
&self,
|
||||
session_id: i32,
|
||||
process_id: &str,
|
||||
) -> Result<PreparedSessionHandles, UnifiedExecError> {
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
let entry = sessions
|
||||
.get_mut(&session_id)
|
||||
.ok_or(UnifiedExecError::UnknownSessionId { session_id })?;
|
||||
let mut store = self.session_store.lock().await;
|
||||
let entry =
|
||||
store
|
||||
.sessions
|
||||
.get_mut(process_id)
|
||||
.ok_or(UnifiedExecError::UnknownSessionId {
|
||||
process_id: process_id.to_string(),
|
||||
})?;
|
||||
entry.last_used = Instant::now();
|
||||
let OutputHandles {
|
||||
output_buffer,
|
||||
@@ -334,6 +387,7 @@ impl UnifiedExecSessionManager {
|
||||
turn_ref: Arc::clone(&entry.turn_ref),
|
||||
command: entry.command.clone(),
|
||||
cwd: entry.cwd.clone(),
|
||||
process_id: entry.process_id.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -347,6 +401,7 @@ impl UnifiedExecSessionManager {
|
||||
.map_err(|_| UnifiedExecError::WriteToStdin)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn store_session(
|
||||
&self,
|
||||
session: UnifiedExecSession,
|
||||
@@ -354,24 +409,35 @@ impl UnifiedExecSessionManager {
|
||||
command: &[String],
|
||||
cwd: PathBuf,
|
||||
started_at: Instant,
|
||||
) -> i32 {
|
||||
let session_id = self
|
||||
.next_session_id
|
||||
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
process_id: String,
|
||||
) {
|
||||
let entry = SessionEntry {
|
||||
session,
|
||||
session_ref: Arc::clone(&context.session),
|
||||
turn_ref: Arc::clone(&context.turn),
|
||||
call_id: context.call_id.clone(),
|
||||
process_id: process_id.clone(),
|
||||
command: command.to_vec(),
|
||||
cwd,
|
||||
started_at,
|
||||
last_used: started_at,
|
||||
};
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
Self::prune_sessions_if_needed(&mut sessions);
|
||||
sessions.insert(session_id, entry);
|
||||
session_id
|
||||
let number_sessions = {
|
||||
let mut store = self.session_store.lock().await;
|
||||
Self::prune_sessions_if_needed(&mut store);
|
||||
store.sessions.insert(process_id, entry);
|
||||
store.sessions.len()
|
||||
};
|
||||
|
||||
if number_sessions >= WARNING_UNIFIED_EXEC_SESSIONS {
|
||||
context
|
||||
.session
|
||||
.record_model_warning(
|
||||
format!("The maximum number of unified exec sessions you can keep open is {WARNING_UNIFIED_EXEC_SESSIONS} and you currently have {number_sessions} sessions open. Reuse older sessions or close them to prevent automatic pruning of old session"),
|
||||
&context.turn
|
||||
)
|
||||
.await;
|
||||
};
|
||||
}
|
||||
|
||||
async fn emit_exec_end_from_entry(
|
||||
@@ -399,6 +465,7 @@ impl UnifiedExecSessionManager {
|
||||
entry.cwd,
|
||||
ExecCommandSource::UnifiedExecStartup,
|
||||
None,
|
||||
Some(entry.process_id.clone()),
|
||||
);
|
||||
emitter
|
||||
.emit(event_ctx, ToolEventStage::Success(output))
|
||||
@@ -412,6 +479,7 @@ impl UnifiedExecSessionManager {
|
||||
aggregated_output: String,
|
||||
exit_code: i32,
|
||||
duration: Duration,
|
||||
process_id: Option<String>,
|
||||
) {
|
||||
let output = ExecToolCallOutput {
|
||||
exit_code,
|
||||
@@ -427,8 +495,13 @@ impl UnifiedExecSessionManager {
|
||||
&context.call_id,
|
||||
None,
|
||||
);
|
||||
let emitter =
|
||||
ToolEmitter::unified_exec(command, cwd, ExecCommandSource::UnifiedExecStartup, None);
|
||||
let emitter = ToolEmitter::unified_exec(
|
||||
command,
|
||||
cwd,
|
||||
ExecCommandSource::UnifiedExecStartup,
|
||||
None,
|
||||
process_id,
|
||||
);
|
||||
emitter
|
||||
.emit(event_ctx, ToolEventStage::Success(output))
|
||||
.await;
|
||||
@@ -481,19 +554,21 @@ impl UnifiedExecSessionManager {
|
||||
let env = apply_unified_exec_env(create_env(&context.turn.shell_environment_policy));
|
||||
let mut orchestrator = ToolOrchestrator::new();
|
||||
let mut runtime = UnifiedExecRuntime::new(self);
|
||||
let approval_requirement = create_approval_requirement_for_command(
|
||||
&context.turn.exec_policy,
|
||||
command,
|
||||
context.turn.approval_policy,
|
||||
&context.turn.sandbox_policy,
|
||||
SandboxPermissions::from(with_escalated_permissions.unwrap_or(false)),
|
||||
)
|
||||
.await;
|
||||
let req = UnifiedExecToolRequest::new(
|
||||
command.to_vec(),
|
||||
cwd,
|
||||
env,
|
||||
with_escalated_permissions,
|
||||
justification,
|
||||
create_approval_requirement_for_command(
|
||||
&context.turn.exec_policy,
|
||||
command,
|
||||
context.turn.approval_policy,
|
||||
&context.turn.sandbox_policy,
|
||||
SandboxPermissions::from(with_escalated_permissions.unwrap_or(false)),
|
||||
),
|
||||
approval_requirement,
|
||||
);
|
||||
let tool_ctx = ToolCtx {
|
||||
session: context.session.as_ref(),
|
||||
@@ -574,52 +649,56 @@ impl UnifiedExecSessionManager {
|
||||
collected
|
||||
}
|
||||
|
||||
fn prune_sessions_if_needed(sessions: &mut HashMap<i32, SessionEntry>) {
|
||||
if sessions.len() < MAX_UNIFIED_EXEC_SESSIONS {
|
||||
return;
|
||||
fn prune_sessions_if_needed(store: &mut SessionStore) -> bool {
|
||||
if store.sessions.len() < MAX_UNIFIED_EXEC_SESSIONS {
|
||||
return false;
|
||||
}
|
||||
|
||||
let meta: Vec<(i32, Instant, bool)> = sessions
|
||||
let meta: Vec<(String, Instant, bool)> = store
|
||||
.sessions
|
||||
.iter()
|
||||
.map(|(id, entry)| (*id, entry.last_used, entry.session.has_exited()))
|
||||
.map(|(id, entry)| (id.clone(), entry.last_used, entry.session.has_exited()))
|
||||
.collect();
|
||||
|
||||
if let Some(session_id) = Self::session_id_to_prune_from_meta(&meta) {
|
||||
sessions.remove(&session_id);
|
||||
store.remove(&session_id);
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
// Centralized pruning policy so we can easily swap strategies later.
|
||||
fn session_id_to_prune_from_meta(meta: &[(i32, Instant, bool)]) -> Option<i32> {
|
||||
fn session_id_to_prune_from_meta(meta: &[(String, Instant, bool)]) -> Option<String> {
|
||||
if meta.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut by_recency = meta.to_vec();
|
||||
by_recency.sort_by_key(|(_, last_used, _)| Reverse(*last_used));
|
||||
let protected: HashSet<i32> = by_recency
|
||||
let protected: HashSet<String> = by_recency
|
||||
.iter()
|
||||
.take(8)
|
||||
.map(|(session_id, _, _)| *session_id)
|
||||
.map(|(process_id, _, _)| process_id.clone())
|
||||
.collect();
|
||||
|
||||
let mut lru = meta.to_vec();
|
||||
lru.sort_by_key(|(_, last_used, _)| *last_used);
|
||||
|
||||
if let Some((session_id, _, _)) = lru
|
||||
if let Some((process_id, _, _)) = lru
|
||||
.iter()
|
||||
.find(|(session_id, _, exited)| !protected.contains(session_id) && *exited)
|
||||
.find(|(process_id, _, exited)| !protected.contains(process_id) && *exited)
|
||||
{
|
||||
return Some(*session_id);
|
||||
return Some(process_id.clone());
|
||||
}
|
||||
|
||||
lru.into_iter()
|
||||
.find(|(session_id, _, _)| !protected.contains(session_id))
|
||||
.map(|(session_id, _, _)| session_id)
|
||||
.find(|(process_id, _, _)| !protected.contains(process_id))
|
||||
.map(|(process_id, _, _)| process_id)
|
||||
}
|
||||
|
||||
pub(crate) async fn terminate_all_sessions(&self) {
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
let mut sessions = self.session_store.lock().await;
|
||||
sessions.clear();
|
||||
}
|
||||
}
|
||||
@@ -628,6 +707,7 @@ enum SessionStatus {
|
||||
Alive {
|
||||
exit_code: Option<i32>,
|
||||
call_id: String,
|
||||
process_id: String,
|
||||
},
|
||||
Exited {
|
||||
exit_code: Option<i32>,
|
||||
@@ -675,64 +755,67 @@ mod tests {
|
||||
#[test]
|
||||
fn pruning_prefers_exited_sessions_outside_recently_used() {
|
||||
let now = Instant::now();
|
||||
let id = |n: i32| n.to_string();
|
||||
let meta = vec![
|
||||
(1, now - Duration::from_secs(40), false),
|
||||
(2, now - Duration::from_secs(30), true),
|
||||
(3, now - Duration::from_secs(20), false),
|
||||
(4, now - Duration::from_secs(19), false),
|
||||
(5, now - Duration::from_secs(18), false),
|
||||
(6, now - Duration::from_secs(17), false),
|
||||
(7, now - Duration::from_secs(16), false),
|
||||
(8, now - Duration::from_secs(15), false),
|
||||
(9, now - Duration::from_secs(14), false),
|
||||
(10, now - Duration::from_secs(13), false),
|
||||
(id(1), now - Duration::from_secs(40), false),
|
||||
(id(2), now - Duration::from_secs(30), true),
|
||||
(id(3), now - Duration::from_secs(20), false),
|
||||
(id(4), now - Duration::from_secs(19), false),
|
||||
(id(5), now - Duration::from_secs(18), false),
|
||||
(id(6), now - Duration::from_secs(17), false),
|
||||
(id(7), now - Duration::from_secs(16), false),
|
||||
(id(8), now - Duration::from_secs(15), false),
|
||||
(id(9), now - Duration::from_secs(14), false),
|
||||
(id(10), now - Duration::from_secs(13), false),
|
||||
];
|
||||
|
||||
let candidate = UnifiedExecSessionManager::session_id_to_prune_from_meta(&meta);
|
||||
|
||||
assert_eq!(candidate, Some(2));
|
||||
assert_eq!(candidate, Some(id(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pruning_falls_back_to_lru_when_no_exited() {
|
||||
let now = Instant::now();
|
||||
let id = |n: i32| n.to_string();
|
||||
let meta = vec![
|
||||
(1, now - Duration::from_secs(40), false),
|
||||
(2, now - Duration::from_secs(30), false),
|
||||
(3, now - Duration::from_secs(20), false),
|
||||
(4, now - Duration::from_secs(19), false),
|
||||
(5, now - Duration::from_secs(18), false),
|
||||
(6, now - Duration::from_secs(17), false),
|
||||
(7, now - Duration::from_secs(16), false),
|
||||
(8, now - Duration::from_secs(15), false),
|
||||
(9, now - Duration::from_secs(14), false),
|
||||
(10, now - Duration::from_secs(13), false),
|
||||
(id(1), now - Duration::from_secs(40), false),
|
||||
(id(2), now - Duration::from_secs(30), false),
|
||||
(id(3), now - Duration::from_secs(20), false),
|
||||
(id(4), now - Duration::from_secs(19), false),
|
||||
(id(5), now - Duration::from_secs(18), false),
|
||||
(id(6), now - Duration::from_secs(17), false),
|
||||
(id(7), now - Duration::from_secs(16), false),
|
||||
(id(8), now - Duration::from_secs(15), false),
|
||||
(id(9), now - Duration::from_secs(14), false),
|
||||
(id(10), now - Duration::from_secs(13), false),
|
||||
];
|
||||
|
||||
let candidate = UnifiedExecSessionManager::session_id_to_prune_from_meta(&meta);
|
||||
|
||||
assert_eq!(candidate, Some(1));
|
||||
assert_eq!(candidate, Some(id(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pruning_protects_recent_sessions_even_if_exited() {
|
||||
let now = Instant::now();
|
||||
let id = |n: i32| n.to_string();
|
||||
let meta = vec![
|
||||
(1, now - Duration::from_secs(40), false),
|
||||
(2, now - Duration::from_secs(30), false),
|
||||
(3, now - Duration::from_secs(20), true),
|
||||
(4, now - Duration::from_secs(19), false),
|
||||
(5, now - Duration::from_secs(18), false),
|
||||
(6, now - Duration::from_secs(17), false),
|
||||
(7, now - Duration::from_secs(16), false),
|
||||
(8, now - Duration::from_secs(15), false),
|
||||
(9, now - Duration::from_secs(14), false),
|
||||
(10, now - Duration::from_secs(13), true),
|
||||
(id(1), now - Duration::from_secs(40), false),
|
||||
(id(2), now - Duration::from_secs(30), false),
|
||||
(id(3), now - Duration::from_secs(20), true),
|
||||
(id(4), now - Duration::from_secs(19), false),
|
||||
(id(5), now - Duration::from_secs(18), false),
|
||||
(id(6), now - Duration::from_secs(17), false),
|
||||
(id(7), now - Duration::from_secs(16), false),
|
||||
(id(8), now - Duration::from_secs(15), false),
|
||||
(id(9), now - Duration::from_secs(14), false),
|
||||
(id(10), now - Duration::from_secs(13), true),
|
||||
];
|
||||
|
||||
let candidate = UnifiedExecSessionManager::session_id_to_prune_from_meta(&meta);
|
||||
|
||||
// (10) is exited but among the last 8; we should drop the LRU outside that set.
|
||||
assert_eq!(candidate, Some(1));
|
||||
assert_eq!(candidate, Some(id(1)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use rand::Rng;
|
||||
@@ -14,11 +16,11 @@ pub(crate) fn backoff(attempt: u64) -> Duration {
|
||||
Duration::from_millis((base as f64 * jitter) as u64)
|
||||
}
|
||||
|
||||
pub(crate) fn error_or_panic(message: String) {
|
||||
pub(crate) fn error_or_panic(message: impl std::string::ToString) {
|
||||
if cfg!(debug_assertions) || env!("CARGO_PKG_VERSION").contains("alpha") {
|
||||
panic!("{message}");
|
||||
panic!("{}", message.to_string());
|
||||
} else {
|
||||
error!("{message}");
|
||||
error!("{}", message.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +39,14 @@ pub(crate) fn try_parse_error_message(text: &str) -> String {
|
||||
text.to_string()
|
||||
}
|
||||
|
||||
pub fn resolve_path(base: &Path, path: &PathBuf) -> PathBuf {
|
||||
if path.is_absolute() {
|
||||
path.clone()
|
||||
} else {
|
||||
base.join(path)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -431,6 +431,9 @@ pub fn ev_apply_patch_call(
|
||||
ApplyPatchModelOutput::ShellViaHeredoc => {
|
||||
ev_apply_patch_shell_call_via_heredoc(call_id, patch)
|
||||
}
|
||||
ApplyPatchModelOutput::ShellCommandViaHeredoc => {
|
||||
ev_apply_patch_shell_command_call_via_heredoc(call_id, patch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -492,6 +495,13 @@ pub fn ev_apply_patch_shell_call_via_heredoc(call_id: &str, patch: &str) -> Valu
|
||||
ev_function_call(call_id, "shell", &arguments)
|
||||
}
|
||||
|
||||
pub fn ev_apply_patch_shell_command_call_via_heredoc(call_id: &str, patch: &str) -> Value {
|
||||
let args = serde_json::json!({ "command": format!("apply_patch <<'EOF'\n{patch}\nEOF\n") });
|
||||
let arguments = serde_json::to_string(&args).expect("serialize apply_patch arguments");
|
||||
|
||||
ev_function_call(call_id, "shell_command", &arguments)
|
||||
}
|
||||
|
||||
pub fn sse_failed(id: &str, code: &str, message: &str) -> String {
|
||||
sse(vec![serde_json::json!({
|
||||
"type": "response.failed",
|
||||
@@ -508,6 +518,32 @@ pub fn sse_response(body: String) -> ResponseTemplate {
|
||||
.set_body_raw(body, "text/event-stream")
|
||||
}
|
||||
|
||||
pub async fn mount_response_once(server: &MockServer, response: ResponseTemplate) -> ResponseMock {
|
||||
let (mock, response_mock) = base_mock();
|
||||
mock.respond_with(response)
|
||||
.up_to_n_times(1)
|
||||
.mount(server)
|
||||
.await;
|
||||
response_mock
|
||||
}
|
||||
|
||||
pub async fn mount_response_once_match<M>(
|
||||
server: &MockServer,
|
||||
matcher: M,
|
||||
response: ResponseTemplate,
|
||||
) -> ResponseMock
|
||||
where
|
||||
M: wiremock::Match + Send + Sync + 'static,
|
||||
{
|
||||
let (mock, response_mock) = base_mock();
|
||||
mock.and(matcher)
|
||||
.respond_with(response)
|
||||
.up_to_n_times(1)
|
||||
.mount(server)
|
||||
.await;
|
||||
response_mock
|
||||
}
|
||||
|
||||
fn base_mock() -> (MockBuilder, ResponseMock) {
|
||||
let response_mock = ResponseMock::new();
|
||||
let mock = Mock::given(method("POST"))
|
||||
|
||||
@@ -36,6 +36,7 @@ pub enum ApplyPatchModelOutput {
|
||||
Function,
|
||||
Shell,
|
||||
ShellViaHeredoc,
|
||||
ShellCommandViaHeredoc,
|
||||
}
|
||||
|
||||
/// A collection of different ways the model can output an apply_patch call
|
||||
@@ -312,7 +313,10 @@ impl TestCodexHarness {
|
||||
ApplyPatchModelOutput::Freeform => self.custom_tool_call_output(call_id).await,
|
||||
ApplyPatchModelOutput::Function
|
||||
| ApplyPatchModelOutput::Shell
|
||||
| ApplyPatchModelOutput::ShellViaHeredoc => self.function_call_stdout(call_id).await,
|
||||
| ApplyPatchModelOutput::ShellViaHeredoc
|
||||
| ApplyPatchModelOutput::ShellCommandViaHeredoc => {
|
||||
self.function_call_stdout(call_id).await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use anyhow::Result;
|
||||
use core_test_support::responses::ev_apply_patch_call;
|
||||
use core_test_support::responses::ev_shell_command_call;
|
||||
use core_test_support::test_codex::ApplyPatchModelOutput;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
@@ -127,6 +128,7 @@ D delete.txt
|
||||
#[test_case(ApplyPatchModelOutput::Function)]
|
||||
#[test_case(ApplyPatchModelOutput::Shell)]
|
||||
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
|
||||
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)]
|
||||
async fn apply_patch_cli_multiple_chunks(model_output: ApplyPatchModelOutput) -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -153,6 +155,7 @@ async fn apply_patch_cli_multiple_chunks(model_output: ApplyPatchModelOutput) ->
|
||||
#[test_case(ApplyPatchModelOutput::Function)]
|
||||
#[test_case(ApplyPatchModelOutput::Shell)]
|
||||
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
|
||||
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)]
|
||||
async fn apply_patch_cli_moves_file_to_new_directory(
|
||||
model_output: ApplyPatchModelOutput,
|
||||
) -> Result<()> {
|
||||
@@ -181,6 +184,7 @@ async fn apply_patch_cli_moves_file_to_new_directory(
|
||||
#[test_case(ApplyPatchModelOutput::Function)]
|
||||
#[test_case(ApplyPatchModelOutput::Shell)]
|
||||
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
|
||||
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)]
|
||||
async fn apply_patch_cli_updates_file_appends_trailing_newline(
|
||||
model_output: ApplyPatchModelOutput,
|
||||
) -> Result<()> {
|
||||
@@ -208,6 +212,7 @@ async fn apply_patch_cli_updates_file_appends_trailing_newline(
|
||||
#[test_case(ApplyPatchModelOutput::Function)]
|
||||
#[test_case(ApplyPatchModelOutput::Shell)]
|
||||
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
|
||||
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)]
|
||||
async fn apply_patch_cli_insert_only_hunk_modifies_file(
|
||||
model_output: ApplyPatchModelOutput,
|
||||
) -> Result<()> {
|
||||
@@ -233,6 +238,7 @@ async fn apply_patch_cli_insert_only_hunk_modifies_file(
|
||||
#[test_case(ApplyPatchModelOutput::Function)]
|
||||
#[test_case(ApplyPatchModelOutput::Shell)]
|
||||
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
|
||||
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)]
|
||||
async fn apply_patch_cli_move_overwrites_existing_destination(
|
||||
model_output: ApplyPatchModelOutput,
|
||||
) -> Result<()> {
|
||||
@@ -263,6 +269,7 @@ async fn apply_patch_cli_move_overwrites_existing_destination(
|
||||
#[test_case(ApplyPatchModelOutput::Function)]
|
||||
#[test_case(ApplyPatchModelOutput::Shell)]
|
||||
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
|
||||
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)]
|
||||
async fn apply_patch_cli_move_without_content_change_has_no_turn_diff(
|
||||
model_output: ApplyPatchModelOutput,
|
||||
) -> Result<()> {
|
||||
@@ -320,6 +327,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff(
|
||||
#[test_case(ApplyPatchModelOutput::Function)]
|
||||
#[test_case(ApplyPatchModelOutput::Shell)]
|
||||
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
|
||||
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)]
|
||||
async fn apply_patch_cli_add_overwrites_existing_file(
|
||||
model_output: ApplyPatchModelOutput,
|
||||
) -> Result<()> {
|
||||
@@ -345,6 +353,7 @@ async fn apply_patch_cli_add_overwrites_existing_file(
|
||||
#[test_case(ApplyPatchModelOutput::Function)]
|
||||
#[test_case(ApplyPatchModelOutput::Shell)]
|
||||
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
|
||||
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)]
|
||||
async fn apply_patch_cli_rejects_invalid_hunk_header(
|
||||
model_output: ApplyPatchModelOutput,
|
||||
) -> Result<()> {
|
||||
@@ -376,6 +385,7 @@ async fn apply_patch_cli_rejects_invalid_hunk_header(
|
||||
#[test_case(ApplyPatchModelOutput::Function)]
|
||||
#[test_case(ApplyPatchModelOutput::Shell)]
|
||||
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
|
||||
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)]
|
||||
async fn apply_patch_cli_reports_missing_context(
|
||||
model_output: ApplyPatchModelOutput,
|
||||
) -> Result<()> {
|
||||
@@ -409,6 +419,7 @@ async fn apply_patch_cli_reports_missing_context(
|
||||
#[test_case(ApplyPatchModelOutput::Function)]
|
||||
#[test_case(ApplyPatchModelOutput::Shell)]
|
||||
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
|
||||
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)]
|
||||
async fn apply_patch_cli_reports_missing_target_file(
|
||||
model_output: ApplyPatchModelOutput,
|
||||
) -> Result<()> {
|
||||
@@ -444,6 +455,7 @@ async fn apply_patch_cli_reports_missing_target_file(
|
||||
#[test_case(ApplyPatchModelOutput::Function)]
|
||||
#[test_case(ApplyPatchModelOutput::Shell)]
|
||||
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
|
||||
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)]
|
||||
async fn apply_patch_cli_delete_missing_file_reports_error(
|
||||
model_output: ApplyPatchModelOutput,
|
||||
) -> Result<()> {
|
||||
@@ -480,6 +492,7 @@ async fn apply_patch_cli_delete_missing_file_reports_error(
|
||||
#[test_case(ApplyPatchModelOutput::Function)]
|
||||
#[test_case(ApplyPatchModelOutput::Shell)]
|
||||
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
|
||||
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)]
|
||||
async fn apply_patch_cli_rejects_empty_patch(model_output: ApplyPatchModelOutput) -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -504,6 +517,7 @@ async fn apply_patch_cli_rejects_empty_patch(model_output: ApplyPatchModelOutput
|
||||
#[test_case(ApplyPatchModelOutput::Function)]
|
||||
#[test_case(ApplyPatchModelOutput::Shell)]
|
||||
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
|
||||
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)]
|
||||
async fn apply_patch_cli_delete_directory_reports_verification_error(
|
||||
model_output: ApplyPatchModelOutput,
|
||||
) -> Result<()> {
|
||||
@@ -530,6 +544,7 @@ async fn apply_patch_cli_delete_directory_reports_verification_error(
|
||||
#[test_case(ApplyPatchModelOutput::Function)]
|
||||
#[test_case(ApplyPatchModelOutput::Shell)]
|
||||
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
|
||||
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)]
|
||||
async fn apply_patch_cli_rejects_path_traversal_outside_workspace(
|
||||
model_output: ApplyPatchModelOutput,
|
||||
) -> Result<()> {
|
||||
@@ -582,6 +597,7 @@ async fn apply_patch_cli_rejects_path_traversal_outside_workspace(
|
||||
#[test_case(ApplyPatchModelOutput::Function)]
|
||||
#[test_case(ApplyPatchModelOutput::Shell)]
|
||||
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
|
||||
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)]
|
||||
async fn apply_patch_cli_rejects_move_path_traversal_outside_workspace(
|
||||
model_output: ApplyPatchModelOutput,
|
||||
) -> Result<()> {
|
||||
@@ -635,6 +651,7 @@ async fn apply_patch_cli_rejects_move_path_traversal_outside_workspace(
|
||||
#[test_case(ApplyPatchModelOutput::Function)]
|
||||
#[test_case(ApplyPatchModelOutput::Shell)]
|
||||
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
|
||||
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)]
|
||||
async fn apply_patch_cli_verification_failure_has_no_side_effects(
|
||||
model_output: ApplyPatchModelOutput,
|
||||
) -> Result<()> {
|
||||
@@ -677,11 +694,10 @@ async fn apply_patch_shell_command_heredoc_with_cd_updates_relative_workdir() ->
|
||||
|
||||
let script = "cd sub && apply_patch <<'EOF'\n*** Begin Patch\n*** Update File: in_sub.txt\n@@\n-before\n+after\n*** End Patch\nEOF\n";
|
||||
let call_id = "shell-heredoc-cd";
|
||||
let args = json!({ "command": script, "timeout_ms": 5_000 });
|
||||
let bodies = vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?),
|
||||
ev_shell_command_call(call_id, script),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
@@ -702,6 +718,86 @@ async fn apply_patch_shell_command_heredoc_with_cd_updates_relative_workdir() ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let harness = apply_patch_harness_with(|builder| builder.with_model("gpt-5.1")).await?;
|
||||
let test = harness.test();
|
||||
let codex = test.codex.clone();
|
||||
let cwd = test.cwd.clone();
|
||||
|
||||
// Prepare a file inside a subdir; update it via cd && apply_patch heredoc form.
|
||||
let sub = test.workspace_path("sub");
|
||||
fs::create_dir_all(&sub)?;
|
||||
let target = sub.join("in_sub.txt");
|
||||
fs::write(&target, "before\n")?;
|
||||
|
||||
let script = "cd sub && apply_patch <<'EOF'\n*** Begin Patch\n*** Update File: in_sub.txt\n@@\n-before\n+after\n*** End Patch\nEOF\n";
|
||||
let call_id = "shell-heredoc-cd";
|
||||
let args = json!({ "command": script, "timeout_ms": 5_000 });
|
||||
let bodies = vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "ok"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
];
|
||||
mount_sse_sequence(harness.server(), bodies).await;
|
||||
|
||||
let model = test.session_configured.model.clone();
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "apply via shell heredoc with cd".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model,
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let mut saw_turn_diff = None;
|
||||
let mut saw_patch_begin = false;
|
||||
let mut patch_end_success = None;
|
||||
wait_for_event(&codex, |event| match event {
|
||||
EventMsg::PatchApplyBegin(begin) => {
|
||||
saw_patch_begin = true;
|
||||
assert_eq!(begin.call_id, call_id);
|
||||
false
|
||||
}
|
||||
EventMsg::PatchApplyEnd(end) => {
|
||||
assert_eq!(end.call_id, call_id);
|
||||
patch_end_success = Some(end.success);
|
||||
false
|
||||
}
|
||||
EventMsg::TurnDiff(ev) => {
|
||||
saw_turn_diff = Some(ev.unified_diff.clone());
|
||||
false
|
||||
}
|
||||
EventMsg::TaskComplete(_) => true,
|
||||
_ => false,
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(saw_patch_begin, "expected PatchApplyBegin event");
|
||||
let patch_end_success =
|
||||
patch_end_success.expect("expected PatchApplyEnd event to capture success flag");
|
||||
assert!(patch_end_success);
|
||||
|
||||
let diff = saw_turn_diff.expect("expected TurnDiff event");
|
||||
assert!(diff.contains("diff --git"), "diff header missing: {diff:?}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
@@ -776,7 +872,11 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() ->
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn apply_patch_function_accepts_lenient_heredoc_wrapped_patch() -> Result<()> {
|
||||
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
|
||||
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)]
|
||||
async fn apply_patch_function_accepts_lenient_heredoc_wrapped_patch(
|
||||
model_output: ApplyPatchModelOutput,
|
||||
) -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let harness = apply_patch_harness().await?;
|
||||
@@ -784,16 +884,8 @@ async fn apply_patch_function_accepts_lenient_heredoc_wrapped_patch() -> Result<
|
||||
let file_name = "lenient.txt";
|
||||
let patch_inner =
|
||||
format!("*** Begin Patch\n*** Add File: {file_name}\n+lenient\n*** End Patch\n");
|
||||
let wrapped = format!("<<'EOF'\n{patch_inner}EOF\n");
|
||||
let call_id = "apply-lenient";
|
||||
mount_apply_patch(
|
||||
&harness,
|
||||
call_id,
|
||||
wrapped.as_str(),
|
||||
"ok",
|
||||
ApplyPatchModelOutput::Function,
|
||||
)
|
||||
.await;
|
||||
mount_apply_patch(&harness, call_id, patch_inner.as_str(), "ok", model_output).await;
|
||||
|
||||
harness.submit("apply lenient heredoc patch").await?;
|
||||
|
||||
@@ -807,6 +899,7 @@ async fn apply_patch_function_accepts_lenient_heredoc_wrapped_patch() -> Result<
|
||||
#[test_case(ApplyPatchModelOutput::Function)]
|
||||
#[test_case(ApplyPatchModelOutput::Shell)]
|
||||
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
|
||||
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)]
|
||||
async fn apply_patch_cli_end_of_file_anchor(model_output: ApplyPatchModelOutput) -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -829,6 +922,7 @@ async fn apply_patch_cli_end_of_file_anchor(model_output: ApplyPatchModelOutput)
|
||||
#[test_case(ApplyPatchModelOutput::Function)]
|
||||
#[test_case(ApplyPatchModelOutput::Shell)]
|
||||
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
|
||||
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)]
|
||||
async fn apply_patch_cli_missing_second_chunk_context_rejected(
|
||||
model_output: ApplyPatchModelOutput,
|
||||
) -> Result<()> {
|
||||
@@ -863,6 +957,7 @@ async fn apply_patch_cli_missing_second_chunk_context_rejected(
|
||||
#[test_case(ApplyPatchModelOutput::Function)]
|
||||
#[test_case(ApplyPatchModelOutput::Shell)]
|
||||
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
|
||||
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)]
|
||||
async fn apply_patch_emits_turn_diff_event_with_unified_diff(
|
||||
model_output: ApplyPatchModelOutput,
|
||||
) -> Result<()> {
|
||||
@@ -918,6 +1013,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff(
|
||||
#[test_case(ApplyPatchModelOutput::Function)]
|
||||
#[test_case(ApplyPatchModelOutput::Shell)]
|
||||
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
|
||||
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)]
|
||||
async fn apply_patch_turn_diff_for_rename_with_content_change(
|
||||
model_output: ApplyPatchModelOutput,
|
||||
) -> Result<()> {
|
||||
@@ -1132,6 +1228,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result
|
||||
#[test_case(ApplyPatchModelOutput::Function)]
|
||||
#[test_case(ApplyPatchModelOutput::Shell)]
|
||||
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
|
||||
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)]
|
||||
async fn apply_patch_change_context_disambiguates_target(
|
||||
model_output: ApplyPatchModelOutput,
|
||||
) -> Result<()> {
|
||||
|
||||
@@ -15,6 +15,7 @@ use codex_core::WireApi;
|
||||
use codex_core::auth::AuthCredentialsStoreMode;
|
||||
use codex_core::built_in_model_providers;
|
||||
use codex_core::error::CodexErr;
|
||||
use codex_core::features::Feature;
|
||||
use codex_core::model_family::find_family_for_model;
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::Op;
|
||||
@@ -34,6 +35,7 @@ use core_test_support::skip_if_no_network;
|
||||
use core_test_support::test_codex::TestCodex;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use dunce::canonicalize as normalize_path;
|
||||
use futures::StreamExt;
|
||||
use serde_json::json;
|
||||
use std::io::Write;
|
||||
@@ -620,6 +622,74 @@ async fn includes_user_instructions_message_in_request() {
|
||||
assert_message_ends_with(&request_body["input"][1], "</environment_context>");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn skills_append_to_instructions_when_feature_enabled() {
|
||||
skip_if_no_network!();
|
||||
let server = MockServer::start().await;
|
||||
|
||||
let resp_mock = responses::mount_sse_once(&server, sse_completed("resp1")).await;
|
||||
|
||||
let model_provider = ModelProviderInfo {
|
||||
base_url: Some(format!("{}/v1", server.uri())),
|
||||
..built_in_model_providers()["openai"].clone()
|
||||
};
|
||||
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let skill_dir = codex_home.path().join("skills/demo");
|
||||
std::fs::create_dir_all(&skill_dir).expect("create skill dir");
|
||||
std::fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
"---\nname: demo\ndescription: build charts\n---\n\n# body\n",
|
||||
)
|
||||
.expect("write skill");
|
||||
|
||||
let mut config = load_default_config_for_test(&codex_home);
|
||||
config.model_provider = model_provider;
|
||||
config.features.enable(Feature::Skills);
|
||||
config.cwd = codex_home.path().to_path_buf();
|
||||
|
||||
let conversation_manager =
|
||||
ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key"));
|
||||
let codex = conversation_manager
|
||||
.new_conversation(config)
|
||||
.await
|
||||
.expect("create new conversation")
|
||||
.conversation;
|
||||
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
}],
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
|
||||
let request = resp_mock.single_request();
|
||||
let request_body = request.body_json();
|
||||
|
||||
assert_message_role(&request_body["input"][0], "user");
|
||||
let instructions_text = request_body["input"][0]["content"][0]["text"]
|
||||
.as_str()
|
||||
.expect("instructions text");
|
||||
assert!(
|
||||
instructions_text.contains("## Skills"),
|
||||
"expected skills section present"
|
||||
);
|
||||
assert!(
|
||||
instructions_text.contains("demo: build charts"),
|
||||
"expected skill summary"
|
||||
);
|
||||
let expected_path = normalize_path(skill_dir.join("SKILL.md")).unwrap();
|
||||
let expected_path_str = expected_path.to_string_lossy().replace('\\', "/");
|
||||
assert!(
|
||||
instructions_text.contains(&expected_path_str),
|
||||
"expected path {expected_path_str} in instructions"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn includes_configured_effort_in_request() -> anyhow::Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -3,6 +3,7 @@ use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::ReviewDecision;
|
||||
use codex_core::protocol::ReviewRequest;
|
||||
use codex_core::protocol::ReviewTarget;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use core_test_support::responses::ev_apply_patch_function_call;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
@@ -68,9 +69,10 @@ async fn codex_delegate_forwards_exec_approval_and_proceeds_on_approval() {
|
||||
test.codex
|
||||
.submit(Op::Review {
|
||||
review_request: ReviewRequest {
|
||||
prompt: "Please review".to_string(),
|
||||
user_facing_hint: "review".to_string(),
|
||||
append_to_original_thread: true,
|
||||
target: ReviewTarget::Custom {
|
||||
instructions: "Please review".to_string(),
|
||||
},
|
||||
user_facing_hint: None,
|
||||
},
|
||||
})
|
||||
.await
|
||||
@@ -144,9 +146,10 @@ async fn codex_delegate_forwards_patch_approval_and_proceeds_on_decision() {
|
||||
test.codex
|
||||
.submit(Op::Review {
|
||||
review_request: ReviewRequest {
|
||||
prompt: "Please review".to_string(),
|
||||
user_facing_hint: "review".to_string(),
|
||||
append_to_original_thread: true,
|
||||
target: ReviewTarget::Custom {
|
||||
instructions: "Please review".to_string(),
|
||||
},
|
||||
user_facing_hint: None,
|
||||
},
|
||||
})
|
||||
.await
|
||||
@@ -199,9 +202,10 @@ async fn codex_delegate_ignores_legacy_deltas() {
|
||||
test.codex
|
||||
.submit(Op::Review {
|
||||
review_request: ReviewRequest {
|
||||
prompt: "Please review".to_string(),
|
||||
user_facing_hint: "review".to_string(),
|
||||
append_to_original_thread: true,
|
||||
target: ReviewTarget::Custom {
|
||||
instructions: "Please review".to_string(),
|
||||
},
|
||||
user_facing_hint: None,
|
||||
},
|
||||
})
|
||||
.await
|
||||
|
||||
@@ -45,6 +45,7 @@ mod resume;
|
||||
mod review;
|
||||
mod rmcp_client;
|
||||
mod rollout_list_find;
|
||||
mod saved_sessions;
|
||||
mod seatbelt;
|
||||
mod shell_serialization;
|
||||
mod stream_error_allows_next_turn;
|
||||
|
||||
@@ -16,8 +16,10 @@ use codex_core::protocol::ReviewFinding;
|
||||
use codex_core::protocol::ReviewLineRange;
|
||||
use codex_core::protocol::ReviewOutputEvent;
|
||||
use codex_core::protocol::ReviewRequest;
|
||||
use codex_core::protocol::ReviewTarget;
|
||||
use codex_core::protocol::RolloutItem;
|
||||
use codex_core::protocol::RolloutLine;
|
||||
use codex_core::review_format::render_review_output_text;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use core_test_support::load_default_config_for_test;
|
||||
use core_test_support::load_sse_fixture_with_id_from_str;
|
||||
@@ -80,9 +82,10 @@ async fn review_op_emits_lifecycle_and_review_output() {
|
||||
codex
|
||||
.submit(Op::Review {
|
||||
review_request: ReviewRequest {
|
||||
prompt: "Please review my changes".to_string(),
|
||||
user_facing_hint: "my changes".to_string(),
|
||||
append_to_original_thread: true,
|
||||
target: ReviewTarget::Custom {
|
||||
instructions: "Please review my changes".to_string(),
|
||||
},
|
||||
user_facing_hint: None,
|
||||
},
|
||||
})
|
||||
.await
|
||||
@@ -124,22 +127,36 @@ async fn review_op_emits_lifecycle_and_review_output() {
|
||||
|
||||
let mut saw_header = false;
|
||||
let mut saw_finding_line = false;
|
||||
let expected_assistant_text = render_review_output_text(&expected);
|
||||
let mut saw_assistant_plain = false;
|
||||
let mut saw_assistant_xml = false;
|
||||
for line in text.lines() {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let v: serde_json::Value = serde_json::from_str(line).expect("jsonl line");
|
||||
let rl: RolloutLine = serde_json::from_value(v).expect("rollout line");
|
||||
if let RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. }) = rl.item
|
||||
&& role == "user"
|
||||
{
|
||||
for c in content {
|
||||
if let ContentItem::InputText { text } = c {
|
||||
if text.contains("full review output from reviewer model") {
|
||||
saw_header = true;
|
||||
if let RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. }) = rl.item {
|
||||
if role == "user" {
|
||||
for c in content {
|
||||
if let ContentItem::InputText { text } = c {
|
||||
if text.contains("full review output from reviewer model") {
|
||||
saw_header = true;
|
||||
}
|
||||
if text.contains("- Prefer Stylize helpers — /tmp/file.rs:10-20") {
|
||||
saw_finding_line = true;
|
||||
}
|
||||
}
|
||||
if text.contains("- Prefer Stylize helpers — /tmp/file.rs:10-20") {
|
||||
saw_finding_line = true;
|
||||
}
|
||||
} else if role == "assistant" {
|
||||
for c in content {
|
||||
if let ContentItem::OutputText { text } = c {
|
||||
if text.contains("<user_action>") {
|
||||
saw_assistant_xml = true;
|
||||
}
|
||||
if text == expected_assistant_text {
|
||||
saw_assistant_plain = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,6 +167,14 @@ async fn review_op_emits_lifecycle_and_review_output() {
|
||||
saw_finding_line,
|
||||
"formatted finding line missing from rollout"
|
||||
);
|
||||
assert!(
|
||||
saw_assistant_plain,
|
||||
"assistant review output missing from rollout"
|
||||
);
|
||||
assert!(
|
||||
!saw_assistant_xml,
|
||||
"assistant review output contains user_action markup"
|
||||
);
|
||||
|
||||
server.verify().await;
|
||||
}
|
||||
@@ -177,9 +202,10 @@ async fn review_op_with_plain_text_emits_review_fallback() {
|
||||
codex
|
||||
.submit(Op::Review {
|
||||
review_request: ReviewRequest {
|
||||
prompt: "Plain text review".to_string(),
|
||||
user_facing_hint: "plain text review".to_string(),
|
||||
append_to_original_thread: true,
|
||||
target: ReviewTarget::Custom {
|
||||
instructions: "Plain text review".to_string(),
|
||||
},
|
||||
user_facing_hint: None,
|
||||
},
|
||||
})
|
||||
.await
|
||||
@@ -236,9 +262,10 @@ async fn review_filters_agent_message_related_events() {
|
||||
codex
|
||||
.submit(Op::Review {
|
||||
review_request: ReviewRequest {
|
||||
prompt: "Filter streaming events".to_string(),
|
||||
user_facing_hint: "Filter streaming events".to_string(),
|
||||
append_to_original_thread: true,
|
||||
target: ReviewTarget::Custom {
|
||||
instructions: "Filter streaming events".to_string(),
|
||||
},
|
||||
user_facing_hint: None,
|
||||
},
|
||||
})
|
||||
.await
|
||||
@@ -247,7 +274,7 @@ async fn review_filters_agent_message_related_events() {
|
||||
let mut saw_entered = false;
|
||||
let mut saw_exited = false;
|
||||
|
||||
// Drain until TaskComplete; assert filtered events never surface.
|
||||
// Drain until TaskComplete; assert streaming-related events never surface.
|
||||
wait_for_event(&codex, |event| match event {
|
||||
EventMsg::TaskComplete(_) => true,
|
||||
EventMsg::EnteredReviewMode(_) => {
|
||||
@@ -265,12 +292,6 @@ async fn review_filters_agent_message_related_events() {
|
||||
EventMsg::AgentMessageDelta(_) => {
|
||||
panic!("unexpected AgentMessageDelta surfaced during review")
|
||||
}
|
||||
EventMsg::ItemCompleted(ev) => match &ev.item {
|
||||
codex_protocol::items::TurnItem::AgentMessage(_) => {
|
||||
panic!("unexpected ItemCompleted for TurnItem::AgentMessage surfaced during review")
|
||||
}
|
||||
_ => false,
|
||||
},
|
||||
_ => false,
|
||||
})
|
||||
.await;
|
||||
@@ -279,8 +300,9 @@ async fn review_filters_agent_message_related_events() {
|
||||
server.verify().await;
|
||||
}
|
||||
|
||||
/// When the model returns structured JSON in a review, ensure no AgentMessage
|
||||
/// is emitted; the UI consumes the structured result via ExitedReviewMode.
|
||||
/// When the model returns structured JSON in a review, ensure only a single
|
||||
/// non-streaming AgentMessage is emitted; the UI consumes the structured
|
||||
/// result via ExitedReviewMode plus a final assistant message.
|
||||
// Windows CI only: bump to 4 workers to prevent SSE/event starvation and test timeouts.
|
||||
#[cfg_attr(windows, tokio::test(flavor = "multi_thread", worker_threads = 4))]
|
||||
#[cfg_attr(not(windows), tokio::test(flavor = "multi_thread", worker_threads = 2))]
|
||||
@@ -321,21 +343,25 @@ async fn review_does_not_emit_agent_message_on_structured_output() {
|
||||
codex
|
||||
.submit(Op::Review {
|
||||
review_request: ReviewRequest {
|
||||
prompt: "check structured".to_string(),
|
||||
user_facing_hint: "check structured".to_string(),
|
||||
append_to_original_thread: true,
|
||||
target: ReviewTarget::Custom {
|
||||
instructions: "check structured".to_string(),
|
||||
},
|
||||
user_facing_hint: None,
|
||||
},
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Drain events until TaskComplete; ensure none are AgentMessage.
|
||||
// Drain events until TaskComplete; ensure we only see a final
|
||||
// AgentMessage (no streaming assistant messages).
|
||||
let mut saw_entered = false;
|
||||
let mut saw_exited = false;
|
||||
let mut agent_messages = 0;
|
||||
wait_for_event(&codex, |event| match event {
|
||||
EventMsg::TaskComplete(_) => true,
|
||||
EventMsg::AgentMessage(_) => {
|
||||
panic!("unexpected AgentMessage during review with structured output")
|
||||
agent_messages += 1;
|
||||
false
|
||||
}
|
||||
EventMsg::EnteredReviewMode(_) => {
|
||||
saw_entered = true;
|
||||
@@ -348,6 +374,7 @@ async fn review_does_not_emit_agent_message_on_structured_output() {
|
||||
_ => false,
|
||||
})
|
||||
.await;
|
||||
assert_eq!(1, agent_messages, "expected exactly one AgentMessage event");
|
||||
assert!(saw_entered && saw_exited, "missing review lifecycle events");
|
||||
|
||||
server.verify().await;
|
||||
@@ -375,9 +402,10 @@ async fn review_uses_custom_review_model_from_config() {
|
||||
codex
|
||||
.submit(Op::Review {
|
||||
review_request: ReviewRequest {
|
||||
prompt: "use custom model".to_string(),
|
||||
user_facing_hint: "use custom model".to_string(),
|
||||
append_to_original_thread: true,
|
||||
target: ReviewTarget::Custom {
|
||||
instructions: "use custom model".to_string(),
|
||||
},
|
||||
user_facing_hint: None,
|
||||
},
|
||||
})
|
||||
.await
|
||||
@@ -493,9 +521,10 @@ async fn review_input_isolated_from_parent_history() {
|
||||
codex
|
||||
.submit(Op::Review {
|
||||
review_request: ReviewRequest {
|
||||
prompt: review_prompt.clone(),
|
||||
user_facing_hint: review_prompt.clone(),
|
||||
append_to_original_thread: true,
|
||||
target: ReviewTarget::Custom {
|
||||
instructions: review_prompt.clone(),
|
||||
},
|
||||
user_facing_hint: None,
|
||||
},
|
||||
})
|
||||
.await
|
||||
@@ -583,11 +612,10 @@ async fn review_input_isolated_from_parent_history() {
|
||||
server.verify().await;
|
||||
}
|
||||
|
||||
/// After a review thread finishes, its conversation should not leak into the
|
||||
/// parent session. A subsequent parent turn must not include any review
|
||||
/// messages in its request `input`.
|
||||
/// After a review thread finishes, its conversation should be visible in the
|
||||
/// parent session so later turns can reference the results.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn review_history_does_not_leak_into_parent_session() {
|
||||
async fn review_history_surfaces_in_parent_session() {
|
||||
skip_if_no_network!();
|
||||
|
||||
// Respond to both the review request and the subsequent parent request.
|
||||
@@ -606,9 +634,10 @@ async fn review_history_does_not_leak_into_parent_session() {
|
||||
codex
|
||||
.submit(Op::Review {
|
||||
review_request: ReviewRequest {
|
||||
prompt: "Start a review".to_string(),
|
||||
user_facing_hint: "Start a review".to_string(),
|
||||
append_to_original_thread: true,
|
||||
target: ReviewTarget::Custom {
|
||||
instructions: "Start a review".to_string(),
|
||||
},
|
||||
user_facing_hint: None,
|
||||
},
|
||||
})
|
||||
.await
|
||||
@@ -651,20 +680,26 @@ async fn review_history_does_not_leak_into_parent_session() {
|
||||
let last_text = last["content"][0]["text"].as_str().unwrap();
|
||||
assert_eq!(last_text, followup);
|
||||
|
||||
// Ensure no review-thread content leaked into the parent request
|
||||
let contains_review_prompt = input
|
||||
.iter()
|
||||
.any(|msg| msg["content"][0]["text"].as_str().unwrap_or_default() == "Start a review");
|
||||
// Ensure review-thread content is present for downstream turns.
|
||||
let contains_review_rollout_user = input.iter().any(|msg| {
|
||||
msg["content"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("User initiated a review task.")
|
||||
});
|
||||
let contains_review_assistant = input.iter().any(|msg| {
|
||||
msg["content"][0]["text"].as_str().unwrap_or_default() == "review assistant output"
|
||||
msg["content"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("review assistant output")
|
||||
});
|
||||
assert!(
|
||||
!contains_review_prompt,
|
||||
"review prompt leaked into parent turn input"
|
||||
contains_review_rollout_user,
|
||||
"review rollout user message missing from parent turn input"
|
||||
);
|
||||
assert!(
|
||||
!contains_review_assistant,
|
||||
"review assistant output leaked into parent turn input"
|
||||
contains_review_assistant,
|
||||
"review assistant output missing from parent turn input"
|
||||
);
|
||||
|
||||
server.verify().await;
|
||||
|
||||
457
codex-rs/core/tests/suite/saved_sessions.rs
Normal file
457
codex-rs/core/tests/suite/saved_sessions.rs
Normal file
@@ -0,0 +1,457 @@
|
||||
#![allow(clippy::expect_used)]
|
||||
use anyhow::Result;
|
||||
use codex_core::AuthManager;
|
||||
use codex_core::CodexAuth;
|
||||
use codex_core::CodexConversation;
|
||||
use codex_core::ConversationManager;
|
||||
use codex_core::SavedSessionEntry;
|
||||
use codex_core::build_saved_session_entry;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::RolloutItem;
|
||||
use codex_core::protocol::RolloutLine;
|
||||
use codex_core::protocol::SaveSessionResponseEvent;
|
||||
use codex_core::protocol::SessionSource;
|
||||
use codex_core::resolve_saved_session;
|
||||
use codex_core::upsert_saved_session;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_response_created;
|
||||
use core_test_support::responses::mount_sse_sequence;
|
||||
use core_test_support::responses::sse;
|
||||
use core_test_support::responses::start_mock_server;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use core_test_support::wait_for_event_match;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn completion_body(idx: usize, message: &str) -> String {
|
||||
let resp_id = format!("resp-{idx}");
|
||||
let msg_id = format!("msg-{idx}");
|
||||
sse(vec![
|
||||
ev_response_created(&resp_id),
|
||||
ev_assistant_message(&msg_id, message),
|
||||
ev_completed(&resp_id),
|
||||
])
|
||||
}
|
||||
|
||||
fn rollout_lines(path: &Path) -> Vec<RolloutLine> {
|
||||
let text = std::fs::read_to_string(path).expect("read rollout");
|
||||
text.lines()
|
||||
.filter_map(|line| {
|
||||
if line.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let value: serde_json::Value = serde_json::from_str(line).expect("rollout line json");
|
||||
Some(serde_json::from_value::<RolloutLine>(value).expect("rollout line"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn rollout_items_without_meta(path: &Path) -> Vec<RolloutItem> {
|
||||
rollout_lines(path)
|
||||
.into_iter()
|
||||
.filter_map(|line| match line.item {
|
||||
RolloutItem::SessionMeta(_) => None,
|
||||
other => Some(other),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn session_meta_count(path: &Path) -> usize {
|
||||
rollout_lines(path)
|
||||
.iter()
|
||||
.filter(|line| matches!(line.item, RolloutItem::SessionMeta(_)))
|
||||
.count()
|
||||
}
|
||||
|
||||
async fn submit_text(codex: &Arc<CodexConversation>, text: &str) -> Result<()> {
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: text.to_string(),
|
||||
}],
|
||||
})
|
||||
.await?;
|
||||
let _ = wait_for_event(codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn save_session(
|
||||
name: &str,
|
||||
codex: &Arc<CodexConversation>,
|
||||
config: &Config,
|
||||
) -> Result<SavedSessionEntry> {
|
||||
codex.flush_rollout().await?;
|
||||
codex.set_session_name(Some(name.to_string())).await?;
|
||||
let entry =
|
||||
build_saved_session_entry(name.to_string(), codex.rollout_path(), codex.model().await)
|
||||
.await?;
|
||||
upsert_saved_session(&config.codex_home, entry.clone()).await?;
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
async fn save_session_via_op(
|
||||
codex: &Arc<CodexConversation>,
|
||||
name: &str,
|
||||
) -> Result<SaveSessionResponseEvent> {
|
||||
codex
|
||||
.submit(Op::SaveSession {
|
||||
name: name.to_string(),
|
||||
})
|
||||
.await?;
|
||||
let response: SaveSessionResponseEvent = wait_for_event_match(codex, |ev| match ev {
|
||||
EventMsg::SaveSessionResponse(resp) => Some(resp.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn save_and_resume_by_name() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
mount_sse_sequence(&server, vec![completion_body(1, "initial")]).await;
|
||||
|
||||
let mut builder = test_codex();
|
||||
let initial = builder.build(&server).await?;
|
||||
submit_text(&initial.codex, "first turn").await?;
|
||||
|
||||
let name = "alpha";
|
||||
let entry = save_session(name, &initial.codex, &initial.config).await?;
|
||||
let resolved = resolve_saved_session(&initial.config.codex_home, name)
|
||||
.await?
|
||||
.expect("saved session");
|
||||
assert_eq!(entry, resolved);
|
||||
assert_eq!(session_meta_count(&entry.rollout_path), 1);
|
||||
|
||||
let saved_items = rollout_items_without_meta(&entry.rollout_path);
|
||||
|
||||
let resumed = builder
|
||||
.resume(&server, initial.home.clone(), entry.rollout_path.clone())
|
||||
.await?;
|
||||
assert_eq!(resumed.session_configured.session_id, entry.conversation_id);
|
||||
let resumed_items = rollout_items_without_meta(&resumed.session_configured.rollout_path);
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(saved_items)?,
|
||||
serde_json::to_value(resumed_items)?
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn save_session_op_persists_and_emits_response() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
mount_sse_sequence(&server, vec![completion_body(1, "initial")]).await;
|
||||
|
||||
let mut builder = test_codex();
|
||||
let initial = builder.build(&server).await?;
|
||||
submit_text(&initial.codex, "first turn").await?;
|
||||
|
||||
let name = "via-op";
|
||||
let response = save_session_via_op(&initial.codex, name).await?;
|
||||
|
||||
assert_eq!(response.name, name);
|
||||
assert_eq!(
|
||||
response.conversation_id,
|
||||
initial.session_configured.session_id
|
||||
);
|
||||
assert!(response.rollout_path.exists());
|
||||
|
||||
let resolved = resolve_saved_session(&initial.config.codex_home, name)
|
||||
.await?
|
||||
.expect("saved session");
|
||||
assert_eq!(resolved.rollout_path, response.rollout_path);
|
||||
assert_eq!(resolved.conversation_id, response.conversation_id);
|
||||
assert_eq!(session_meta_count(&resolved.rollout_path), 1);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn fork_from_identifier_after_save_op() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
completion_body(1, "seed"),
|
||||
completion_body(2, "fork-extra-1"),
|
||||
completion_body(3, "fork-extra-2"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex();
|
||||
let initial = builder.build(&server).await?;
|
||||
submit_text(&initial.codex, "seeded").await?;
|
||||
|
||||
let name = "forkable-op";
|
||||
let response = save_session_via_op(&initial.codex, name).await?;
|
||||
|
||||
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("dummy"));
|
||||
let conversation_manager = ConversationManager::new(auth_manager.clone(), SessionSource::Exec);
|
||||
let forked = conversation_manager
|
||||
.fork_from_identifier(initial.config.clone(), name, auth_manager)
|
||||
.await?;
|
||||
|
||||
assert_ne!(
|
||||
forked.session_configured.session_id,
|
||||
response.conversation_id
|
||||
);
|
||||
|
||||
// Record the baseline rollout for the saved session.
|
||||
let base_items = rollout_items_without_meta(&response.rollout_path);
|
||||
|
||||
// Send additional turns to the forked conversation and flush.
|
||||
submit_text(&forked.conversation, "fork one").await?;
|
||||
submit_text(&forked.conversation, "fork two").await?;
|
||||
forked.conversation.flush_rollout().await?;
|
||||
|
||||
// Re-read both rollouts: source should remain unchanged.
|
||||
let base_after = rollout_items_without_meta(&response.rollout_path);
|
||||
assert_eq!(
|
||||
serde_json::to_value(&base_items)?,
|
||||
serde_json::to_value(&base_after)?
|
||||
);
|
||||
|
||||
// Forked rollout should extend the baseline.
|
||||
let fork_items = rollout_items_without_meta(&forked.conversation.rollout_path());
|
||||
assert!(
|
||||
fork_items.len() > base_items.len(),
|
||||
"expected forked rollout to contain additional items"
|
||||
);
|
||||
let fork_prefix: Vec<_> = fork_items.iter().take(base_items.len()).cloned().collect();
|
||||
assert_eq!(
|
||||
serde_json::to_value(&base_items)?,
|
||||
serde_json::to_value(&fork_prefix)?,
|
||||
"forked rollout should extend the baseline history"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn save_and_fork_by_name() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
mount_sse_sequence(&server, vec![completion_body(1, "base")]).await;
|
||||
|
||||
let mut builder = test_codex();
|
||||
let initial = builder.build(&server).await?;
|
||||
submit_text(&initial.codex, "original").await?;
|
||||
|
||||
let entry = save_session("forkable", &initial.codex, &initial.config).await?;
|
||||
|
||||
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("dummy"));
|
||||
let conversation_manager = ConversationManager::new(auth_manager.clone(), SessionSource::Exec);
|
||||
let forked = conversation_manager
|
||||
.fork_from_rollout(
|
||||
initial.config.clone(),
|
||||
entry.rollout_path.clone(),
|
||||
auth_manager,
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_ne!(forked.session_configured.session_id, entry.conversation_id);
|
||||
assert_ne!(forked.conversation.rollout_path(), entry.rollout_path);
|
||||
assert_eq!(session_meta_count(&forked.conversation.rollout_path()), 1);
|
||||
|
||||
let base_items = rollout_items_without_meta(&entry.rollout_path);
|
||||
let fork_items = rollout_items_without_meta(&forked.conversation.rollout_path());
|
||||
assert_eq!(
|
||||
serde_json::to_value(base_items)?,
|
||||
serde_json::to_value(fork_items)?
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn forked_messages_do_not_touch_original() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
completion_body(1, "base"),
|
||||
completion_body(2, "fork-1"),
|
||||
completion_body(3, "fork-2"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex();
|
||||
let initial = builder.build(&server).await?;
|
||||
submit_text(&initial.codex, "first").await?;
|
||||
|
||||
let entry = save_session("branch", &initial.codex, &initial.config).await?;
|
||||
let baseline_items = rollout_items_without_meta(&entry.rollout_path);
|
||||
|
||||
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("dummy"));
|
||||
let conversation_manager = ConversationManager::new(auth_manager.clone(), SessionSource::Exec);
|
||||
let forked = conversation_manager
|
||||
.fork_from_rollout(
|
||||
initial.config.clone(),
|
||||
entry.rollout_path.clone(),
|
||||
auth_manager.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
submit_text(&forked.conversation, "fork message one").await?;
|
||||
submit_text(&forked.conversation, "fork message two").await?;
|
||||
|
||||
let resumed = builder
|
||||
.resume(&server, initial.home.clone(), entry.rollout_path.clone())
|
||||
.await?;
|
||||
let resumed_items = rollout_items_without_meta(&resumed.session_configured.rollout_path);
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(baseline_items.clone())?,
|
||||
serde_json::to_value(resumed_items)?
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(baseline_items)?,
|
||||
serde_json::to_value(rollout_items_without_meta(&entry.rollout_path))?
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn resumed_messages_are_present_in_new_fork() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
completion_body(1, "original"),
|
||||
completion_body(2, "fork-extra"),
|
||||
completion_body(3, "resumed-extra"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex();
|
||||
let initial = builder.build(&server).await?;
|
||||
submit_text(&initial.codex, "start").await?;
|
||||
|
||||
let entry = save_session("seed", &initial.codex, &initial.config).await?;
|
||||
|
||||
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("dummy"));
|
||||
let conversation_manager = ConversationManager::new(auth_manager.clone(), SessionSource::Exec);
|
||||
let forked = conversation_manager
|
||||
.fork_from_rollout(
|
||||
initial.config.clone(),
|
||||
entry.rollout_path.clone(),
|
||||
auth_manager.clone(),
|
||||
)
|
||||
.await?;
|
||||
submit_text(&forked.conversation, "fork only").await?;
|
||||
|
||||
let resumed = builder
|
||||
.resume(&server, initial.home.clone(), entry.rollout_path.clone())
|
||||
.await?;
|
||||
submit_text(&resumed.codex, "resumed addition").await?;
|
||||
resumed.codex.flush_rollout().await?;
|
||||
let updated_base_items = rollout_items_without_meta(&entry.rollout_path);
|
||||
|
||||
let fork_again = conversation_manager
|
||||
.fork_from_rollout(
|
||||
initial.config.clone(),
|
||||
entry.rollout_path.clone(),
|
||||
auth_manager,
|
||||
)
|
||||
.await?;
|
||||
let fork_again_items = rollout_items_without_meta(&fork_again.conversation.rollout_path());
|
||||
assert_eq!(
|
||||
serde_json::to_value(updated_base_items)?,
|
||||
serde_json::to_value(fork_again_items)?
|
||||
);
|
||||
assert_eq!(
|
||||
session_meta_count(&fork_again.conversation.rollout_path()),
|
||||
1
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn duplicate_name_overwrites_entry() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
mount_sse_sequence(
|
||||
&server,
|
||||
vec![completion_body(1, "one"), completion_body(2, "two")],
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex();
|
||||
let first = builder.build(&server).await?;
|
||||
submit_text(&first.codex, "first session").await?;
|
||||
let name = "shared";
|
||||
let entry_one = save_session(name, &first.codex, &first.config).await?;
|
||||
|
||||
let second = builder.build(&server).await?;
|
||||
submit_text(&second.codex, "second session").await?;
|
||||
let entry_two = save_session(name, &second.codex, &second.config).await?;
|
||||
|
||||
let resolved = resolve_saved_session(&second.config.codex_home, name)
|
||||
.await?
|
||||
.expect("latest entry present");
|
||||
assert_eq!(resolved, entry_two);
|
||||
assert_ne!(resolved.conversation_id, entry_one.conversation_id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn same_session_multiple_names() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
mount_sse_sequence(&server, vec![completion_body(1, "hello")]).await;
|
||||
|
||||
let mut builder = test_codex();
|
||||
let session = builder.build(&server).await?;
|
||||
submit_text(&session.codex, "save twice").await?;
|
||||
|
||||
let entry_first = save_session("first", &session.codex, &session.config).await?;
|
||||
let entry_second = save_session("second", &session.codex, &session.config).await?;
|
||||
|
||||
let resolved_first = resolve_saved_session(&session.config.codex_home, "first")
|
||||
.await?
|
||||
.expect("first entry");
|
||||
let resolved_second = resolve_saved_session(&session.config.codex_home, "second")
|
||||
.await?
|
||||
.expect("second entry");
|
||||
|
||||
assert_eq!(entry_first.conversation_id, entry_second.conversation_id);
|
||||
assert_eq!(
|
||||
resolved_first.conversation_id,
|
||||
resolved_second.conversation_id
|
||||
);
|
||||
assert_eq!(resolved_first.rollout_path, resolved_second.rollout_path);
|
||||
|
||||
let names: serde_json::Value = json!([entry_first.name, entry_second.name]);
|
||||
assert_eq!(names, json!(["first", "second"]));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
#![cfg(not(target_os = "windows"))]
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::OsStr;
|
||||
use std::fs;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use anyhow::Context;
|
||||
@@ -23,6 +25,7 @@ use core_test_support::responses::start_mock_server;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::skip_if_sandbox;
|
||||
use core_test_support::test_codex::TestCodex;
|
||||
use core_test_support::test_codex::TestCodexHarness;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use core_test_support::wait_for_event_match;
|
||||
@@ -44,7 +47,7 @@ fn extract_output_text(item: &Value) -> Option<&str> {
|
||||
struct ParsedUnifiedExecOutput {
|
||||
chunk_id: Option<String>,
|
||||
wall_time_seconds: f64,
|
||||
session_id: Option<i32>,
|
||||
process_id: Option<String>,
|
||||
exit_code: Option<i32>,
|
||||
original_token_count: Option<usize>,
|
||||
output: String,
|
||||
@@ -59,7 +62,7 @@ fn parse_unified_exec_output(raw: &str) -> Result<ParsedUnifiedExecOutput> {
|
||||
r#"(?:Chunk ID: (?P<chunk_id>[^\n]+)\n)?"#,
|
||||
r#"Wall time: (?P<wall_time>-?\d+(?:\.\d+)?) seconds\n"#,
|
||||
r#"(?:Process exited with code (?P<exit_code>-?\d+)\n)?"#,
|
||||
r#"(?:Process running with session ID (?P<session_id>-?\d+)\n)?"#,
|
||||
r#"(?:Process running with session ID (?P<process_id>-?\d+)\n)?"#,
|
||||
r#"(?:Original token count: (?P<original_token_count>\d+)\n)?"#,
|
||||
r#"Output:\n?(?P<output>.*)$"#,
|
||||
))
|
||||
@@ -92,15 +95,9 @@ fn parse_unified_exec_output(raw: &str) -> Result<ParsedUnifiedExecOutput> {
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
let session_id = captures
|
||||
.name("session_id")
|
||||
.map(|value| {
|
||||
value
|
||||
.as_str()
|
||||
.parse::<i32>()
|
||||
.context("failed to parse session id from unified exec output")
|
||||
})
|
||||
.transpose()?;
|
||||
let process_id = captures
|
||||
.name("process_id")
|
||||
.map(|value| value.as_str().to_string());
|
||||
|
||||
let original_token_count = captures
|
||||
.name("original_token_count")
|
||||
@@ -121,7 +118,7 @@ fn parse_unified_exec_output(raw: &str) -> Result<ParsedUnifiedExecOutput> {
|
||||
Ok(ParsedUnifiedExecOutput {
|
||||
chunk_id,
|
||||
wall_time_seconds,
|
||||
session_id,
|
||||
process_id,
|
||||
exit_code,
|
||||
original_token_count,
|
||||
output,
|
||||
@@ -154,6 +151,130 @@ fn collect_tool_outputs(bodies: &[Value]) -> Result<HashMap<String, ParsedUnifie
|
||||
Ok(outputs)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
skip_if_sandbox!(Ok(()));
|
||||
|
||||
let builder = test_codex().with_config(|config| {
|
||||
config.include_apply_patch_tool = true;
|
||||
config.use_experimental_unified_exec_tool = true;
|
||||
config.features.enable(Feature::UnifiedExec);
|
||||
});
|
||||
let harness = TestCodexHarness::with_builder(builder).await?;
|
||||
|
||||
let patch =
|
||||
"*** Begin Patch\n*** Add File: uexec_apply.txt\n+hello from unified exec\n*** End Patch";
|
||||
let command = format!("apply_patch <<'EOF'\n{patch}\nEOF\n");
|
||||
let call_id = "uexec-apply-patch";
|
||||
let args = json!({
|
||||
"cmd": command,
|
||||
"yield_time_ms": 250,
|
||||
});
|
||||
|
||||
let responses = vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-2"),
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
];
|
||||
mount_sse_sequence(harness.server(), responses).await;
|
||||
|
||||
let test = harness.test();
|
||||
let codex = test.codex.clone();
|
||||
let cwd = test.cwd_path().to_path_buf();
|
||||
let session_model = test.session_configured.model.clone();
|
||||
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "apply patch via unified exec".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd,
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let mut saw_patch_begin = false;
|
||||
let mut patch_end = None;
|
||||
let mut saw_exec_begin = false;
|
||||
let mut saw_exec_end = false;
|
||||
wait_for_event(&codex, |event| match event {
|
||||
EventMsg::PatchApplyBegin(begin) if begin.call_id == call_id => {
|
||||
saw_patch_begin = true;
|
||||
assert!(
|
||||
begin
|
||||
.changes
|
||||
.keys()
|
||||
.any(|path| path.file_name() == Some(OsStr::new("uexec_apply.txt"))),
|
||||
"expected apply_patch changes to target uexec_apply.txt",
|
||||
);
|
||||
false
|
||||
}
|
||||
EventMsg::PatchApplyEnd(end) if end.call_id == call_id => {
|
||||
patch_end = Some(end.clone());
|
||||
false
|
||||
}
|
||||
EventMsg::ExecCommandBegin(event) if event.call_id == call_id => {
|
||||
saw_exec_begin = true;
|
||||
false
|
||||
}
|
||||
EventMsg::ExecCommandEnd(event) if event.call_id == call_id => {
|
||||
saw_exec_end = true;
|
||||
false
|
||||
}
|
||||
EventMsg::TaskComplete(_) => true,
|
||||
_ => false,
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
saw_patch_begin,
|
||||
"expected apply_patch to emit PatchApplyBegin"
|
||||
);
|
||||
let patch_end = patch_end.expect("expected apply_patch to emit PatchApplyEnd");
|
||||
assert!(
|
||||
patch_end.success,
|
||||
"expected apply_patch to finish successfully: stdout={:?} stderr={:?}",
|
||||
patch_end.stdout, patch_end.stderr,
|
||||
);
|
||||
assert!(
|
||||
!saw_exec_begin,
|
||||
"apply_patch should be intercepted before exec_command begin"
|
||||
);
|
||||
assert!(
|
||||
!saw_exec_end,
|
||||
"apply_patch should not emit exec_command end events"
|
||||
);
|
||||
|
||||
let output = harness.function_call_stdout(call_id).await;
|
||||
assert!(
|
||||
output.contains("Success. Updated the following files:"),
|
||||
"expected apply_patch output, got: {output:?}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("A uexec_apply.txt"),
|
||||
"expected apply_patch file summary, got: {output:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(harness.path("uexec_apply.txt"))?,
|
||||
"hello from unified exec\n"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn unified_exec_emits_exec_command_begin_event() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
@@ -174,6 +295,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> {
|
||||
|
||||
let call_id = "uexec-begin-event";
|
||||
let args = json!({
|
||||
"shell": "bash".to_string(),
|
||||
"cmd": "/bin/echo hello unified exec".to_string(),
|
||||
"yield_time_ms": 250,
|
||||
});
|
||||
@@ -215,14 +337,8 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> {
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
begin_event.command,
|
||||
vec![
|
||||
"/bin/bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
"/bin/echo hello unified exec".to_string()
|
||||
]
|
||||
);
|
||||
assert_command(&begin_event.command, "-lc", "/bin/echo hello unified exec");
|
||||
|
||||
assert_eq!(begin_event.cwd, cwd.path());
|
||||
|
||||
wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await;
|
||||
@@ -230,6 +346,82 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn unified_exec_resolves_relative_workdir() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
skip_if_sandbox!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
|
||||
let mut builder = test_codex().with_model("gpt-5").with_config(|config| {
|
||||
config.use_experimental_unified_exec_tool = true;
|
||||
config.features.enable(Feature::UnifiedExec);
|
||||
});
|
||||
let TestCodex {
|
||||
codex,
|
||||
cwd,
|
||||
session_configured,
|
||||
..
|
||||
} = builder.build(&server).await?;
|
||||
|
||||
let workdir_rel = std::path::PathBuf::from("uexec_relative_workdir");
|
||||
std::fs::create_dir_all(cwd.path().join(&workdir_rel))?;
|
||||
|
||||
let call_id = "uexec-workdir-relative";
|
||||
let args = json!({
|
||||
"cmd": "pwd",
|
||||
"yield_time_ms": 250,
|
||||
"workdir": workdir_rel.to_string_lossy().to_string(),
|
||||
});
|
||||
|
||||
let responses = vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-2"),
|
||||
ev_assistant_message("msg-1", "finished"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
];
|
||||
mount_sse_sequence(&server, responses).await;
|
||||
|
||||
let session_model = session_configured.model.clone();
|
||||
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "run relative workdir test".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let begin_event = wait_for_event_match(&codex, |msg| match msg {
|
||||
EventMsg::ExecCommandBegin(event) if event.call_id == call_id => Some(event.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
begin_event.cwd,
|
||||
cwd.path().join(workdir_rel),
|
||||
"exec_command cwd should resolve relative workdir against turn cwd",
|
||||
);
|
||||
|
||||
wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "flaky"]
|
||||
async fn unified_exec_respects_workdir_override() -> Result<()> {
|
||||
@@ -335,7 +527,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> {
|
||||
let poll_call_id = "uexec-end-event-poll";
|
||||
let poll_args = json!({
|
||||
"chars": "",
|
||||
"session_id": 0,
|
||||
"session_id": 1000,
|
||||
"yield_time_ms": 250,
|
||||
});
|
||||
|
||||
@@ -493,7 +685,7 @@ async fn unified_exec_emits_output_delta_for_write_stdin() -> Result<()> {
|
||||
let stdin_call_id = "uexec-stdin-delta";
|
||||
let stdin_args = json!({
|
||||
"chars": "echo WSTDIN-MARK\\n",
|
||||
"session_id": 0,
|
||||
"session_id": 1000,
|
||||
"yield_time_ms": 800,
|
||||
});
|
||||
|
||||
@@ -585,6 +777,7 @@ async fn unified_exec_emits_begin_for_write_stdin() -> Result<()> {
|
||||
|
||||
let open_call_id = "uexec-open-for-begin";
|
||||
let open_args = json!({
|
||||
"shell": "bash".to_string(),
|
||||
"cmd": "bash -i".to_string(),
|
||||
"yield_time_ms": 200,
|
||||
});
|
||||
@@ -592,7 +785,7 @@ async fn unified_exec_emits_begin_for_write_stdin() -> Result<()> {
|
||||
let stdin_call_id = "uexec-stdin-begin";
|
||||
let stdin_args = json!({
|
||||
"chars": "echo hello",
|
||||
"session_id": 0,
|
||||
"session_id": 1000,
|
||||
"yield_time_ms": 400,
|
||||
});
|
||||
|
||||
@@ -646,14 +839,7 @@ async fn unified_exec_emits_begin_for_write_stdin() -> Result<()> {
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
begin_event.command,
|
||||
vec![
|
||||
"/bin/bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
"bash -i".to_string()
|
||||
]
|
||||
);
|
||||
assert_command(&begin_event.command, "-lc", "bash -i");
|
||||
assert_eq!(
|
||||
begin_event.interaction_input,
|
||||
Some("echo hello".to_string())
|
||||
@@ -687,6 +873,7 @@ async fn unified_exec_emits_begin_event_for_write_stdin_requests() -> Result<()>
|
||||
|
||||
let open_call_id = "uexec-open-session";
|
||||
let open_args = json!({
|
||||
"shell": "bash".to_string(),
|
||||
"cmd": "bash -i".to_string(),
|
||||
"yield_time_ms": 250,
|
||||
});
|
||||
@@ -694,7 +881,7 @@ async fn unified_exec_emits_begin_event_for_write_stdin_requests() -> Result<()>
|
||||
let poll_call_id = "uexec-poll-empty";
|
||||
let poll_args = json!({
|
||||
"chars": "",
|
||||
"session_id": 0,
|
||||
"session_id": 1000,
|
||||
"yield_time_ms": 150,
|
||||
});
|
||||
|
||||
@@ -762,14 +949,9 @@ async fn unified_exec_emits_begin_event_for_write_stdin_requests() -> Result<()>
|
||||
.iter()
|
||||
.find(|ev| ev.call_id == open_call_id)
|
||||
.expect("missing exec_command begin");
|
||||
assert_eq!(
|
||||
open_event.command,
|
||||
vec![
|
||||
"/bin/bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
"bash -i".to_string()
|
||||
]
|
||||
);
|
||||
|
||||
assert_command(&open_event.command, "-lc", "bash -i");
|
||||
|
||||
assert!(
|
||||
open_event.interaction_input.is_none(),
|
||||
"startup begin events should not include interaction input"
|
||||
@@ -780,14 +962,9 @@ async fn unified_exec_emits_begin_event_for_write_stdin_requests() -> Result<()>
|
||||
.iter()
|
||||
.find(|ev| ev.call_id == poll_call_id)
|
||||
.expect("missing write_stdin begin");
|
||||
assert_eq!(
|
||||
poll_event.command,
|
||||
vec![
|
||||
"/bin/bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
"bash -i".to_string()
|
||||
]
|
||||
);
|
||||
|
||||
assert_command(&poll_event.command, "-lc", "bash -i");
|
||||
|
||||
assert!(
|
||||
poll_event.interaction_input.is_none(),
|
||||
"poll begin events should omit interaction input"
|
||||
@@ -880,8 +1057,8 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> {
|
||||
);
|
||||
|
||||
assert!(
|
||||
metadata.session_id.is_none(),
|
||||
"exec_command for a completed process should not include session_id"
|
||||
metadata.process_id.is_none(),
|
||||
"exec_command for a completed process should not include process_id"
|
||||
);
|
||||
|
||||
let exit_code = metadata.exit_code.expect("expected exit_code");
|
||||
@@ -973,7 +1150,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> {
|
||||
.expect("missing early exit unified_exec output");
|
||||
|
||||
assert!(
|
||||
output.session_id.is_none(),
|
||||
output.process_id.is_none(),
|
||||
"short-lived process should not keep a session alive"
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -1023,12 +1200,12 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> {
|
||||
});
|
||||
let send_args = serde_json::json!({
|
||||
"chars": "hello unified exec\n",
|
||||
"session_id": 0,
|
||||
"session_id": 1000,
|
||||
"yield_time_ms": 500,
|
||||
});
|
||||
let exit_args = serde_json::json!({
|
||||
"chars": "\u{0004}",
|
||||
"session_id": 0,
|
||||
"session_id": 1000,
|
||||
"yield_time_ms": 500,
|
||||
});
|
||||
|
||||
@@ -1099,12 +1276,13 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> {
|
||||
let start_output = outputs
|
||||
.get(start_call_id)
|
||||
.expect("missing start output for exec_command");
|
||||
let session_id = start_output
|
||||
.session_id
|
||||
.expect("expected session id from exec_command");
|
||||
let process_id = start_output
|
||||
.process_id
|
||||
.clone()
|
||||
.expect("expected process id from exec_command");
|
||||
assert!(
|
||||
session_id >= 0,
|
||||
"session_id should be non-negative, got {session_id}"
|
||||
process_id.len() > 3,
|
||||
"process_id should be at least 4 digits, got {process_id}"
|
||||
);
|
||||
assert!(
|
||||
start_output.exit_code.is_none(),
|
||||
@@ -1120,11 +1298,12 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> {
|
||||
"expected echoed output from cat, got {echoed:?}"
|
||||
);
|
||||
let echoed_session = send_output
|
||||
.session_id
|
||||
.expect("write_stdin should return session id while process is running");
|
||||
.process_id
|
||||
.clone()
|
||||
.expect("write_stdin should return process id while process is running");
|
||||
assert_eq!(
|
||||
echoed_session, session_id,
|
||||
"write_stdin should reuse existing session id"
|
||||
echoed_session, process_id,
|
||||
"write_stdin should reuse existing process id"
|
||||
);
|
||||
assert!(
|
||||
send_output.exit_code.is_none(),
|
||||
@@ -1135,8 +1314,8 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> {
|
||||
.get(exit_call_id)
|
||||
.expect("missing exit metadata output");
|
||||
assert!(
|
||||
exit_output.session_id.is_none(),
|
||||
"session_id should be omitted once the process exits"
|
||||
exit_output.process_id.is_none(),
|
||||
"process_id should be omitted once the process exits"
|
||||
);
|
||||
let exit_code = exit_output
|
||||
.exit_code
|
||||
@@ -1182,14 +1361,14 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<()
|
||||
let echo_call_id = "uexec-end-on-exit-echo";
|
||||
let echo_args = serde_json::json!({
|
||||
"chars": "bye-END\n",
|
||||
"session_id": 0,
|
||||
"session_id": 1000,
|
||||
"yield_time_ms": 300,
|
||||
});
|
||||
|
||||
let exit_call_id = "uexec-end-on-exit";
|
||||
let exit_args = serde_json::json!({
|
||||
"chars": "\u{0004}",
|
||||
"session_id": 0,
|
||||
"session_id": 1000,
|
||||
"yield_time_ms": 500,
|
||||
});
|
||||
|
||||
@@ -1285,7 +1464,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> {
|
||||
let second_call_id = "uexec-stdin";
|
||||
let second_args = serde_json::json!({
|
||||
"chars": "hello unified exec\n",
|
||||
"session_id": 0,
|
||||
"session_id": 1000,
|
||||
"yield_time_ms": 500,
|
||||
});
|
||||
|
||||
@@ -1347,17 +1526,20 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> {
|
||||
let start_output = outputs
|
||||
.get(first_call_id)
|
||||
.expect("missing first unified_exec output");
|
||||
let session_id = start_output.session_id.unwrap_or_default();
|
||||
let process_id = start_output.process_id.clone().unwrap_or_default();
|
||||
assert!(
|
||||
session_id >= 0,
|
||||
"expected session id in first unified_exec response"
|
||||
!process_id.is_empty(),
|
||||
"expected process id in first unified_exec response"
|
||||
);
|
||||
assert!(start_output.output.is_empty());
|
||||
|
||||
let reuse_output = outputs
|
||||
.get(second_call_id)
|
||||
.expect("missing reused unified_exec output");
|
||||
assert_eq!(reuse_output.session_id.unwrap_or_default(), session_id);
|
||||
assert_eq!(
|
||||
reuse_output.process_id.clone().unwrap_or_default(),
|
||||
process_id
|
||||
);
|
||||
let echoed = reuse_output.output.as_str();
|
||||
assert!(
|
||||
echoed.contains("hello unified exec"),
|
||||
@@ -1413,7 +1595,7 @@ PY
|
||||
let second_call_id = "uexec-lag-poll";
|
||||
let second_args = serde_json::json!({
|
||||
"chars": "",
|
||||
"session_id": 0,
|
||||
"session_id": 1000,
|
||||
"yield_time_ms": 2_000,
|
||||
});
|
||||
|
||||
@@ -1480,9 +1662,9 @@ PY
|
||||
let start_output = outputs
|
||||
.get(first_call_id)
|
||||
.expect("missing initial unified_exec output");
|
||||
let session_id = start_output.session_id.unwrap_or_default();
|
||||
let process_id = start_output.process_id.clone().unwrap_or_default();
|
||||
assert!(
|
||||
session_id >= 0,
|
||||
!process_id.is_empty(),
|
||||
"expected session id from initial unified_exec response"
|
||||
);
|
||||
|
||||
@@ -1524,7 +1706,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> {
|
||||
let second_call_id = "uexec-poll";
|
||||
let second_args = serde_json::json!({
|
||||
"chars": "",
|
||||
"session_id": 0,
|
||||
"session_id": 1000,
|
||||
"yield_time_ms": 800,
|
||||
});
|
||||
|
||||
@@ -1589,7 +1771,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> {
|
||||
let outputs = collect_tool_outputs(&bodies)?;
|
||||
|
||||
let first_output = outputs.get(first_call_id).expect("missing timeout output");
|
||||
assert_eq!(first_output.session_id, Some(0));
|
||||
assert!(first_output.process_id.is_some());
|
||||
assert!(first_output.output.is_empty());
|
||||
|
||||
let poll_output = outputs.get(second_call_id).expect("missing poll output");
|
||||
@@ -1824,7 +2006,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> {
|
||||
let keep_write_call_id = "uexec-prune-keep-write";
|
||||
let keep_write_args = serde_json::json!({
|
||||
"chars": "still alive\n",
|
||||
"session_id": 0,
|
||||
"session_id": 1000,
|
||||
"yield_time_ms": 500,
|
||||
});
|
||||
events.push(ev_function_call(
|
||||
@@ -1836,7 +2018,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> {
|
||||
let probe_call_id = "uexec-prune-probe";
|
||||
let probe_args = serde_json::json!({
|
||||
"chars": "should fail\n",
|
||||
"session_id": 1,
|
||||
"session_id": 1001,
|
||||
"yield_time_ms": 500,
|
||||
});
|
||||
events.push(ev_function_call(
|
||||
@@ -1885,7 +2067,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> {
|
||||
.find_map(|req| req.function_call_output_text(keep_call_id))
|
||||
.expect("missing initial keep session output");
|
||||
let keep_start_output = parse_unified_exec_output(&keep_start)?;
|
||||
pretty_assertions::assert_eq!(keep_start_output.session_id, Some(0));
|
||||
assert!(keep_start_output.process_id.is_some());
|
||||
assert!(keep_start_output.exit_code.is_none());
|
||||
|
||||
let prune_start = requests
|
||||
@@ -1893,7 +2075,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> {
|
||||
.find_map(|req| req.function_call_output_text(prune_call_id))
|
||||
.expect("missing initial prune session output");
|
||||
let prune_start_output = parse_unified_exec_output(&prune_start)?;
|
||||
pretty_assertions::assert_eq!(prune_start_output.session_id, Some(1));
|
||||
assert!(prune_start_output.process_id.is_some());
|
||||
assert!(prune_start_output.exit_code.is_none());
|
||||
|
||||
let keep_write = requests
|
||||
@@ -1901,7 +2083,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> {
|
||||
.find_map(|req| req.function_call_output_text(keep_write_call_id))
|
||||
.expect("missing keep write output");
|
||||
let keep_write_output = parse_unified_exec_output(&keep_write)?;
|
||||
pretty_assertions::assert_eq!(keep_write_output.session_id, Some(0));
|
||||
assert!(keep_write_output.process_id.is_some());
|
||||
assert!(
|
||||
keep_write_output.output.contains("still alive"),
|
||||
"expected cat session to echo input, got {:?}",
|
||||
@@ -1913,9 +2095,23 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> {
|
||||
.find_map(|req| req.function_call_output_text(probe_call_id))
|
||||
.expect("missing probe output");
|
||||
assert!(
|
||||
pruned_probe.contains("UnknownSessionId") || pruned_probe.contains("Unknown session id"),
|
||||
pruned_probe.contains("UnknownSessionId") || pruned_probe.contains("Unknown process id"),
|
||||
"expected probe to fail after pruning, got {pruned_probe:?}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn assert_command(command: &[String], expected_args: &str, expected_cmd: &str) {
|
||||
assert_eq!(command.len(), 3);
|
||||
let shell_path = &command[0];
|
||||
assert!(
|
||||
shell_path == "/bin/bash"
|
||||
|| shell_path == "/usr/bin/bash"
|
||||
|| shell_path == "/usr/local/bin/bash"
|
||||
|| shell_path.ends_with("/bash"),
|
||||
"unexpected bash path: {shell_path}"
|
||||
);
|
||||
assert_eq!(command[1], expected_args);
|
||||
assert_eq!(command[2], expected_cmd);
|
||||
}
|
||||
|
||||
@@ -474,3 +474,82 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
|
||||
const INVALID_IMAGE_ERROR: &str =
|
||||
"The image data you provided does not represent a valid image";
|
||||
|
||||
let invalid_image_mock = responses::mount_response_once_match(
|
||||
&server,
|
||||
body_string_contains("\"input_image\""),
|
||||
ResponseTemplate::new(400)
|
||||
.insert_header("content-type", "text/plain")
|
||||
.set_body_string(INVALID_IMAGE_ERROR),
|
||||
)
|
||||
.await;
|
||||
|
||||
let success_response = sse(vec![
|
||||
ev_response_created("resp-2"),
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-2"),
|
||||
]);
|
||||
|
||||
let completion_mock = responses::mount_sse_once(&server, success_response).await;
|
||||
|
||||
let TestCodex {
|
||||
codex,
|
||||
cwd,
|
||||
session_configured,
|
||||
..
|
||||
} = test_codex().build(&server).await?;
|
||||
|
||||
let rel_path = "assets/poisoned.png";
|
||||
let abs_path = cwd.path().join(rel_path);
|
||||
if let Some(parent) = abs_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let image = ImageBuffer::from_pixel(1024, 512, Rgba([10u8, 20, 30, 255]));
|
||||
image.save(&abs_path)?;
|
||||
|
||||
let session_model = session_configured.model.clone();
|
||||
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::LocalImage {
|
||||
path: abs_path.clone(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
})
|
||||
.await?;
|
||||
|
||||
wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await;
|
||||
|
||||
let first_body = invalid_image_mock.single_request().body_json();
|
||||
assert!(
|
||||
find_image_message(&first_body).is_some(),
|
||||
"initial request should include the uploaded image"
|
||||
);
|
||||
|
||||
let second_request = completion_mock.single_request();
|
||||
let second_body = second_request.body_json();
|
||||
assert!(
|
||||
find_image_message(&second_body).is_none(),
|
||||
"second request should replace the invalid image"
|
||||
);
|
||||
let user_texts = second_request.message_input_texts("user");
|
||||
assert!(user_texts.iter().any(|text| text == "Invalid image"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ rustPlatform.buildRustPackage (_: {
|
||||
cargoLock.outputHashes = {
|
||||
"ratatui-0.29.0" = "sha256-HBvT5c8GsiCxMffNjJGLmHnvG77A6cqEL+1ARurBXho=";
|
||||
"crossterm-0.28.1" = "sha256-6qCtfSMuXACKFb9ATID39XyFDIEMFDmbx6SSmNe+728=";
|
||||
"rmcp-0.9.0" = "sha256-0iPrpf0Ha/facO3p5e0hUKHBqGp/iS+C+OdS+pRKMOU=";
|
||||
};
|
||||
|
||||
meta = with lib; {
|
||||
|
||||
@@ -78,6 +78,7 @@ mod stopwatch;
|
||||
const CODEX_EXECVE_WRAPPER_EXE_NAME: &str = "codex-execve-wrapper";
|
||||
|
||||
#[derive(Parser)]
|
||||
#[clap(version)]
|
||||
struct McpServerCli {
|
||||
/// Executable to delegate execve(2) calls to in Bash.
|
||||
#[arg(long = "execve")]
|
||||
|
||||
@@ -91,6 +91,9 @@ pub struct Cli {
|
||||
pub enum Command {
|
||||
/// Resume a previous session by id or pick the most recent with --last.
|
||||
Resume(ResumeArgs),
|
||||
|
||||
/// Run a code review against the current repository.
|
||||
Review(ReviewArgs),
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
@@ -109,6 +112,41 @@ pub struct ResumeArgs {
|
||||
pub prompt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
pub struct ReviewArgs {
|
||||
/// Review staged, unstaged, and untracked changes.
|
||||
#[arg(
|
||||
long = "uncommitted",
|
||||
default_value_t = false,
|
||||
conflicts_with_all = ["base", "commit", "prompt"]
|
||||
)]
|
||||
pub uncommitted: bool,
|
||||
|
||||
/// Review changes against the given base branch.
|
||||
#[arg(
|
||||
long = "base",
|
||||
value_name = "BRANCH",
|
||||
conflicts_with_all = ["uncommitted", "commit", "prompt"]
|
||||
)]
|
||||
pub base: Option<String>,
|
||||
|
||||
/// Review the changes introduced by a commit.
|
||||
#[arg(
|
||||
long = "commit",
|
||||
value_name = "SHA",
|
||||
conflicts_with_all = ["uncommitted", "base", "prompt"]
|
||||
)]
|
||||
pub commit: Option<String>,
|
||||
|
||||
/// Optional commit title to display in the review summary.
|
||||
#[arg(long = "title", value_name = "TITLE", requires = "commit")]
|
||||
pub commit_title: Option<String>,
|
||||
|
||||
/// Custom review instructions. If `-` is used, read from stdin.
|
||||
#[arg(value_name = "PROMPT", value_hint = clap::ValueHint::Other)]
|
||||
pub prompt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)]
|
||||
#[value(rename_all = "kebab-case")]
|
||||
pub enum Color {
|
||||
|
||||
@@ -583,6 +583,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
| EventMsg::ReasoningContentDelta(_)
|
||||
| EventMsg::ReasoningRawContentDelta(_)
|
||||
| EventMsg::UndoCompleted(_)
|
||||
| EventMsg::SaveSessionResponse(_)
|
||||
| EventMsg::UndoStarted(_) => {}
|
||||
}
|
||||
CodexStatus::Running
|
||||
|
||||
@@ -11,6 +11,8 @@ pub mod event_processor_with_jsonl_output;
|
||||
pub mod exec_events;
|
||||
|
||||
pub use cli::Cli;
|
||||
pub use cli::Command;
|
||||
pub use cli::ReviewArgs;
|
||||
use codex_common::oss::ensure_oss_provider_ready;
|
||||
use codex_common::oss::get_default_model_for_oss_provider;
|
||||
use codex_core::AuthManager;
|
||||
@@ -29,6 +31,8 @@ use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::Event;
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::ReviewRequest;
|
||||
use codex_core::protocol::ReviewTarget;
|
||||
use codex_core::protocol::SessionSource;
|
||||
use codex_protocol::approvals::ElicitationAction;
|
||||
use codex_protocol::config_types::SandboxMode;
|
||||
@@ -53,6 +57,16 @@ use crate::event_processor::EventProcessor;
|
||||
use codex_core::default_client::set_default_originator;
|
||||
use codex_core::find_conversation_path_by_id_str;
|
||||
|
||||
enum InitialOperation {
|
||||
UserTurn {
|
||||
items: Vec<UserInput>,
|
||||
output_schema: Option<Value>,
|
||||
},
|
||||
Review {
|
||||
review_request: ReviewRequest,
|
||||
},
|
||||
}
|
||||
|
||||
pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()> {
|
||||
if let Err(err) = set_default_originator("codex_exec".to_string()) {
|
||||
tracing::warn!(?err, "Failed to set codex exec originator override {err:?}");
|
||||
@@ -79,64 +93,6 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
config_overrides,
|
||||
} = cli;
|
||||
|
||||
// Determine the prompt source (parent or subcommand) and read from stdin if needed.
|
||||
let prompt_arg = match &command {
|
||||
// Allow prompt before the subcommand by falling back to the parent-level prompt
|
||||
// when the Resume subcommand did not provide its own prompt.
|
||||
Some(ExecCommand::Resume(args)) => {
|
||||
let resume_prompt = args
|
||||
.prompt
|
||||
.clone()
|
||||
// When using `resume --last <PROMPT>`, clap still parses the first positional
|
||||
// as `session_id`. Reinterpret it as the prompt so the flag works with JSON mode.
|
||||
.or_else(|| {
|
||||
if args.last {
|
||||
args.session_id.clone()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
resume_prompt.or(prompt)
|
||||
}
|
||||
None => prompt,
|
||||
};
|
||||
|
||||
let prompt = match prompt_arg {
|
||||
Some(p) if p != "-" => p,
|
||||
// Either `-` was passed or no positional arg.
|
||||
maybe_dash => {
|
||||
// When no arg (None) **and** stdin is a TTY, bail out early – unless the
|
||||
// user explicitly forced reading via `-`.
|
||||
let force_stdin = matches!(maybe_dash.as_deref(), Some("-"));
|
||||
|
||||
if std::io::stdin().is_terminal() && !force_stdin {
|
||||
eprintln!(
|
||||
"No prompt provided. Either specify one as an argument or pipe the prompt into stdin."
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
// Ensure the user knows we are waiting on stdin, as they may
|
||||
// have gotten into this state by mistake. If so, and they are not
|
||||
// writing to stdin, Codex will hang indefinitely, so this should
|
||||
// help them debug in that case.
|
||||
if !force_stdin {
|
||||
eprintln!("Reading prompt from stdin...");
|
||||
}
|
||||
let mut buffer = String::new();
|
||||
if let Err(e) = std::io::stdin().read_to_string(&mut buffer) {
|
||||
eprintln!("Failed to read prompt from stdin: {e}");
|
||||
std::process::exit(1);
|
||||
} else if buffer.trim().is_empty() {
|
||||
eprintln!("No prompt provided via stdin.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
buffer
|
||||
}
|
||||
};
|
||||
|
||||
let output_schema = load_output_schema(output_schema_path);
|
||||
|
||||
let (stdout_with_ansi, stderr_with_ansi) = match color {
|
||||
cli::Color::Always => (true, true),
|
||||
cli::Color::Never => (false, false),
|
||||
@@ -329,8 +285,8 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
conversation_id: _,
|
||||
conversation,
|
||||
session_configured,
|
||||
} = if let Some(ExecCommand::Resume(args)) = command {
|
||||
let resume_path = resolve_resume_path(&config, &args).await?;
|
||||
} = if let Some(ExecCommand::Resume(args)) = command.as_ref() {
|
||||
let resume_path = resolve_resume_path(&config, args).await?;
|
||||
|
||||
if let Some(path) = resume_path {
|
||||
conversation_manager
|
||||
@@ -346,9 +302,64 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
.new_conversation(config.clone())
|
||||
.await?
|
||||
};
|
||||
// Print the effective configuration and prompt so users can see what Codex
|
||||
let (initial_operation, prompt_summary) = match (command, prompt, images) {
|
||||
(Some(ExecCommand::Review(review_cli)), _, _) => {
|
||||
let review_request = build_review_request(review_cli)?;
|
||||
let summary = codex_core::review_prompts::user_facing_hint(&review_request.target);
|
||||
(InitialOperation::Review { review_request }, summary)
|
||||
}
|
||||
(Some(ExecCommand::Resume(args)), root_prompt, imgs) => {
|
||||
let prompt_arg = args
|
||||
.prompt
|
||||
.clone()
|
||||
.or_else(|| {
|
||||
if args.last {
|
||||
args.session_id.clone()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.or(root_prompt);
|
||||
let prompt_text = resolve_prompt(prompt_arg);
|
||||
let mut items: Vec<UserInput> = imgs
|
||||
.into_iter()
|
||||
.map(|path| UserInput::LocalImage { path })
|
||||
.collect();
|
||||
items.push(UserInput::Text {
|
||||
text: prompt_text.clone(),
|
||||
});
|
||||
let output_schema = load_output_schema(output_schema_path.clone());
|
||||
(
|
||||
InitialOperation::UserTurn {
|
||||
items,
|
||||
output_schema,
|
||||
},
|
||||
prompt_text,
|
||||
)
|
||||
}
|
||||
(None, root_prompt, imgs) => {
|
||||
let prompt_text = resolve_prompt(root_prompt);
|
||||
let mut items: Vec<UserInput> = imgs
|
||||
.into_iter()
|
||||
.map(|path| UserInput::LocalImage { path })
|
||||
.collect();
|
||||
items.push(UserInput::Text {
|
||||
text: prompt_text.clone(),
|
||||
});
|
||||
let output_schema = load_output_schema(output_schema_path);
|
||||
(
|
||||
InitialOperation::UserTurn {
|
||||
items,
|
||||
output_schema,
|
||||
},
|
||||
prompt_text,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
// Print the effective configuration and initial request so users can see what Codex
|
||||
// is using.
|
||||
event_processor.print_config_summary(&config, &prompt, &session_configured);
|
||||
event_processor.print_config_summary(&config, &prompt_summary, &session_configured);
|
||||
|
||||
info!("Codex initialized with event: {session_configured:?}");
|
||||
|
||||
@@ -391,25 +402,32 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
});
|
||||
}
|
||||
|
||||
// Package images and prompt into a single user input turn.
|
||||
let mut items: Vec<UserInput> = images
|
||||
.into_iter()
|
||||
.map(|path| UserInput::LocalImage { path })
|
||||
.collect();
|
||||
items.push(UserInput::Text { text: prompt });
|
||||
let initial_prompt_task_id = conversation
|
||||
.submit(Op::UserTurn {
|
||||
match initial_operation {
|
||||
InitialOperation::UserTurn {
|
||||
items,
|
||||
cwd: default_cwd,
|
||||
approval_policy: default_approval_policy,
|
||||
sandbox_policy: default_sandbox_policy,
|
||||
model: default_model,
|
||||
effort: default_effort,
|
||||
summary: default_summary,
|
||||
final_output_json_schema: output_schema,
|
||||
})
|
||||
.await?;
|
||||
info!("Sent prompt with event ID: {initial_prompt_task_id}");
|
||||
output_schema,
|
||||
} => {
|
||||
let task_id = conversation
|
||||
.submit(Op::UserTurn {
|
||||
items,
|
||||
cwd: default_cwd,
|
||||
approval_policy: default_approval_policy,
|
||||
sandbox_policy: default_sandbox_policy,
|
||||
model: default_model,
|
||||
effort: default_effort,
|
||||
summary: default_summary,
|
||||
final_output_json_schema: output_schema,
|
||||
})
|
||||
.await?;
|
||||
info!("Sent prompt with event ID: {task_id}");
|
||||
task_id
|
||||
}
|
||||
InitialOperation::Review { review_request } => {
|
||||
let task_id = conversation.submit(Op::Review { review_request }).await?;
|
||||
info!("Sent review request with event ID: {task_id}");
|
||||
task_id
|
||||
}
|
||||
};
|
||||
|
||||
// Run the loop until the task is complete.
|
||||
// Track whether a fatal error was reported by the server so we can
|
||||
@@ -503,3 +521,130 @@ fn load_output_schema(path: Option<PathBuf>) -> Option<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_prompt(prompt_arg: Option<String>) -> String {
|
||||
match prompt_arg {
|
||||
Some(p) if p != "-" => p,
|
||||
maybe_dash => {
|
||||
let force_stdin = matches!(maybe_dash.as_deref(), Some("-"));
|
||||
|
||||
if std::io::stdin().is_terminal() && !force_stdin {
|
||||
eprintln!(
|
||||
"No prompt provided. Either specify one as an argument or pipe the prompt into stdin."
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
if !force_stdin {
|
||||
eprintln!("Reading prompt from stdin...");
|
||||
}
|
||||
let mut buffer = String::new();
|
||||
if let Err(e) = std::io::stdin().read_to_string(&mut buffer) {
|
||||
eprintln!("Failed to read prompt from stdin: {e}");
|
||||
std::process::exit(1);
|
||||
} else if buffer.trim().is_empty() {
|
||||
eprintln!("No prompt provided via stdin.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
buffer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_review_request(args: ReviewArgs) -> anyhow::Result<ReviewRequest> {
|
||||
let target = if args.uncommitted {
|
||||
ReviewTarget::UncommittedChanges
|
||||
} else if let Some(branch) = args.base {
|
||||
ReviewTarget::BaseBranch { branch }
|
||||
} else if let Some(sha) = args.commit {
|
||||
ReviewTarget::Commit {
|
||||
sha,
|
||||
title: args.commit_title,
|
||||
}
|
||||
} else if let Some(prompt_arg) = args.prompt {
|
||||
let prompt = resolve_prompt(Some(prompt_arg)).trim().to_string();
|
||||
if prompt.is_empty() {
|
||||
anyhow::bail!("Review prompt cannot be empty");
|
||||
}
|
||||
ReviewTarget::Custom {
|
||||
instructions: prompt,
|
||||
}
|
||||
} else {
|
||||
anyhow::bail!(
|
||||
"Specify --uncommitted, --base, --commit, or provide custom review instructions"
|
||||
);
|
||||
};
|
||||
|
||||
Ok(ReviewRequest {
|
||||
target,
|
||||
user_facing_hint: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn builds_uncommitted_review_request() {
|
||||
let request = build_review_request(ReviewArgs {
|
||||
uncommitted: true,
|
||||
base: None,
|
||||
commit: None,
|
||||
commit_title: None,
|
||||
prompt: None,
|
||||
})
|
||||
.expect("builds uncommitted review request");
|
||||
|
||||
let expected = ReviewRequest {
|
||||
target: ReviewTarget::UncommittedChanges,
|
||||
user_facing_hint: None,
|
||||
};
|
||||
|
||||
assert_eq!(request, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_commit_review_request_with_title() {
|
||||
let request = build_review_request(ReviewArgs {
|
||||
uncommitted: false,
|
||||
base: None,
|
||||
commit: Some("123456789".to_string()),
|
||||
commit_title: Some("Add review command".to_string()),
|
||||
prompt: None,
|
||||
})
|
||||
.expect("builds commit review request");
|
||||
|
||||
let expected = ReviewRequest {
|
||||
target: ReviewTarget::Commit {
|
||||
sha: "123456789".to_string(),
|
||||
title: Some("Add review command".to_string()),
|
||||
},
|
||||
user_facing_hint: None,
|
||||
};
|
||||
|
||||
assert_eq!(request, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_custom_review_request_trims_prompt() {
|
||||
let request = build_review_request(ReviewArgs {
|
||||
uncommitted: false,
|
||||
base: None,
|
||||
commit: None,
|
||||
commit_title: None,
|
||||
prompt: Some(" custom review instructions ".to_string()),
|
||||
})
|
||||
.expect("builds custom review request");
|
||||
|
||||
let expected = ReviewRequest {
|
||||
target: ReviewTarget::Custom {
|
||||
instructions: "custom review instructions".to_string(),
|
||||
},
|
||||
user_facing_hint: None,
|
||||
};
|
||||
|
||||
assert_eq!(request, expected);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -637,6 +637,7 @@ fn exec_command_end_success_produces_completed_command_item() {
|
||||
"c1",
|
||||
EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
|
||||
call_id: "1".to_string(),
|
||||
process_id: None,
|
||||
turn_id: "turn-1".to_string(),
|
||||
command: command.clone(),
|
||||
cwd: cwd.clone(),
|
||||
@@ -666,6 +667,7 @@ fn exec_command_end_success_produces_completed_command_item() {
|
||||
"c2",
|
||||
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id: "1".to_string(),
|
||||
process_id: None,
|
||||
turn_id: "turn-1".to_string(),
|
||||
command,
|
||||
cwd,
|
||||
@@ -709,6 +711,7 @@ fn exec_command_end_failure_produces_failed_command_item() {
|
||||
"c1",
|
||||
EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
|
||||
call_id: "2".to_string(),
|
||||
process_id: None,
|
||||
turn_id: "turn-1".to_string(),
|
||||
command: command.clone(),
|
||||
cwd: cwd.clone(),
|
||||
@@ -737,6 +740,7 @@ fn exec_command_end_failure_produces_failed_command_item() {
|
||||
"c2",
|
||||
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id: "2".to_string(),
|
||||
process_id: None,
|
||||
turn_id: "turn-1".to_string(),
|
||||
command,
|
||||
cwd,
|
||||
@@ -777,6 +781,7 @@ fn exec_command_end_without_begin_is_ignored() {
|
||||
"c1",
|
||||
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id: "no-begin".to_string(),
|
||||
process_id: None,
|
||||
turn_id: "turn-1".to_string(),
|
||||
command: Vec::new(),
|
||||
cwd: PathBuf::from("."),
|
||||
|
||||
@@ -28,3 +28,4 @@ thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
|
||||
226
codex-rs/execpolicy/src/amend.rs
Normal file
226
codex-rs/execpolicy/src/amend.rs
Normal file
@@ -0,0 +1,226 @@
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Read;
|
||||
use std::io::Seek;
|
||||
use std::io::SeekFrom;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde_json;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AmendError {
|
||||
#[error("prefix rule requires at least one token")]
|
||||
EmptyPrefix,
|
||||
#[error("policy path has no parent: {path}")]
|
||||
MissingParent { path: PathBuf },
|
||||
#[error("failed to create policy directory {dir}: {source}")]
|
||||
CreatePolicyDir {
|
||||
dir: PathBuf,
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("failed to format prefix tokens: {source}")]
|
||||
SerializePrefix { source: serde_json::Error },
|
||||
#[error("failed to open policy file {path}: {source}")]
|
||||
OpenPolicyFile {
|
||||
path: PathBuf,
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("failed to write to policy file {path}: {source}")]
|
||||
WritePolicyFile {
|
||||
path: PathBuf,
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("failed to lock policy file {path}: {source}")]
|
||||
LockPolicyFile {
|
||||
path: PathBuf,
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("failed to seek policy file {path}: {source}")]
|
||||
SeekPolicyFile {
|
||||
path: PathBuf,
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("failed to read policy file {path}: {source}")]
|
||||
ReadPolicyFile {
|
||||
path: PathBuf,
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("failed to read metadata for policy file {path}: {source}")]
|
||||
PolicyMetadata {
|
||||
path: PathBuf,
|
||||
source: std::io::Error,
|
||||
},
|
||||
}
|
||||
|
||||
/// Note this thread uses advisory file locking and performs blocking I/O, so it should be used with
|
||||
/// [`tokio::task::spawn_blocking`] when called from an async context.
|
||||
pub fn blocking_append_allow_prefix_rule(
|
||||
policy_path: &Path,
|
||||
prefix: &[String],
|
||||
) -> Result<(), AmendError> {
|
||||
if prefix.is_empty() {
|
||||
return Err(AmendError::EmptyPrefix);
|
||||
}
|
||||
|
||||
let tokens = prefix
|
||||
.iter()
|
||||
.map(serde_json::to_string)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|source| AmendError::SerializePrefix { source })?;
|
||||
let pattern = format!("[{}]", tokens.join(", "));
|
||||
let rule = format!(r#"prefix_rule(pattern={pattern}, decision="allow")"#);
|
||||
|
||||
let dir = policy_path
|
||||
.parent()
|
||||
.ok_or_else(|| AmendError::MissingParent {
|
||||
path: policy_path.to_path_buf(),
|
||||
})?;
|
||||
match std::fs::create_dir(dir) {
|
||||
Ok(()) => {}
|
||||
Err(ref source) if source.kind() == std::io::ErrorKind::AlreadyExists => {}
|
||||
Err(source) => {
|
||||
return Err(AmendError::CreatePolicyDir {
|
||||
dir: dir.to_path_buf(),
|
||||
source,
|
||||
});
|
||||
}
|
||||
}
|
||||
append_locked_line(policy_path, &rule)
|
||||
}
|
||||
|
||||
fn append_locked_line(policy_path: &Path, line: &str) -> Result<(), AmendError> {
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.append(true)
|
||||
.open(policy_path)
|
||||
.map_err(|source| AmendError::OpenPolicyFile {
|
||||
path: policy_path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
file.lock().map_err(|source| AmendError::LockPolicyFile {
|
||||
path: policy_path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
let len = file
|
||||
.metadata()
|
||||
.map_err(|source| AmendError::PolicyMetadata {
|
||||
path: policy_path.to_path_buf(),
|
||||
source,
|
||||
})?
|
||||
.len();
|
||||
|
||||
// Ensure file ends in a newline before appending.
|
||||
if len > 0 {
|
||||
file.seek(SeekFrom::End(-1))
|
||||
.map_err(|source| AmendError::SeekPolicyFile {
|
||||
path: policy_path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
let mut last = [0; 1];
|
||||
file.read_exact(&mut last)
|
||||
.map_err(|source| AmendError::ReadPolicyFile {
|
||||
path: policy_path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
if last[0] != b'\n' {
|
||||
file.write_all(b"\n")
|
||||
.map_err(|source| AmendError::WritePolicyFile {
|
||||
path: policy_path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
file.write_all(format!("{line}\n").as_bytes())
|
||||
.map_err(|source| AmendError::WritePolicyFile {
|
||||
path: policy_path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn appends_rule_and_creates_directories() {
|
||||
let tmp = tempdir().expect("create temp dir");
|
||||
let policy_path = tmp.path().join("policy").join("default.codexpolicy");
|
||||
|
||||
blocking_append_allow_prefix_rule(
|
||||
&policy_path,
|
||||
&[String::from("echo"), String::from("Hello, world!")],
|
||||
)
|
||||
.expect("append rule");
|
||||
|
||||
let contents =
|
||||
std::fs::read_to_string(&policy_path).expect("default.codexpolicy should exist");
|
||||
assert_eq!(
|
||||
contents,
|
||||
r#"prefix_rule(pattern=["echo", "Hello, world!"], decision="allow")
|
||||
"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appends_rule_without_duplicate_newline() {
|
||||
let tmp = tempdir().expect("create temp dir");
|
||||
let policy_path = tmp.path().join("policy").join("default.codexpolicy");
|
||||
std::fs::create_dir_all(policy_path.parent().unwrap()).expect("create policy dir");
|
||||
std::fs::write(
|
||||
&policy_path,
|
||||
r#"prefix_rule(pattern=["ls"], decision="allow")
|
||||
"#,
|
||||
)
|
||||
.expect("write seed rule");
|
||||
|
||||
blocking_append_allow_prefix_rule(
|
||||
&policy_path,
|
||||
&[String::from("echo"), String::from("Hello, world!")],
|
||||
)
|
||||
.expect("append rule");
|
||||
|
||||
let contents = std::fs::read_to_string(&policy_path).expect("read policy");
|
||||
assert_eq!(
|
||||
contents,
|
||||
r#"prefix_rule(pattern=["ls"], decision="allow")
|
||||
prefix_rule(pattern=["echo", "Hello, world!"], decision="allow")
|
||||
"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserts_newline_when_missing_before_append() {
|
||||
let tmp = tempdir().expect("create temp dir");
|
||||
let policy_path = tmp.path().join("policy").join("default.codexpolicy");
|
||||
std::fs::create_dir_all(policy_path.parent().unwrap()).expect("create policy dir");
|
||||
std::fs::write(
|
||||
&policy_path,
|
||||
r#"prefix_rule(pattern=["ls"], decision="allow")"#,
|
||||
)
|
||||
.expect("write seed rule without newline");
|
||||
|
||||
blocking_append_allow_prefix_rule(
|
||||
&policy_path,
|
||||
&[String::from("echo"), String::from("Hello, world!")],
|
||||
)
|
||||
.expect("append rule");
|
||||
|
||||
let contents = std::fs::read_to_string(&policy_path).expect("read policy");
|
||||
assert_eq!(
|
||||
contents,
|
||||
r#"prefix_rule(pattern=["ls"], decision="allow")
|
||||
prefix_rule(pattern=["echo", "Hello, world!"], decision="allow")
|
||||
"#
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod amend;
|
||||
pub mod decision;
|
||||
pub mod error;
|
||||
pub mod execpolicycheck;
|
||||
@@ -5,6 +6,8 @@ pub mod parser;
|
||||
pub mod policy;
|
||||
pub mod rule;
|
||||
|
||||
pub use amend::AmendError;
|
||||
pub use amend::blocking_append_allow_prefix_rule;
|
||||
pub use decision::Decision;
|
||||
pub use error::Error;
|
||||
pub use error::Result;
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
use crate::decision::Decision;
|
||||
use crate::error::Error;
|
||||
use crate::error::Result;
|
||||
use crate::rule::PatternToken;
|
||||
use crate::rule::PrefixPattern;
|
||||
use crate::rule::PrefixRule;
|
||||
use crate::rule::RuleMatch;
|
||||
use crate::rule::RuleRef;
|
||||
use multimap::MultiMap;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Policy {
|
||||
@@ -23,6 +29,27 @@ impl Policy {
|
||||
&self.rules_by_program
|
||||
}
|
||||
|
||||
pub fn add_prefix_rule(&mut self, prefix: &[String], decision: Decision) -> Result<()> {
|
||||
let (first_token, rest) = prefix
|
||||
.split_first()
|
||||
.ok_or_else(|| Error::InvalidPattern("prefix cannot be empty".to_string()))?;
|
||||
|
||||
let rule: RuleRef = Arc::new(PrefixRule {
|
||||
pattern: PrefixPattern {
|
||||
first: Arc::from(first_token.as_str()),
|
||||
rest: rest
|
||||
.iter()
|
||||
.map(|token| PatternToken::Single(token.clone()))
|
||||
.collect::<Vec<_>>()
|
||||
.into(),
|
||||
},
|
||||
decision,
|
||||
});
|
||||
|
||||
self.rules_by_program.insert(first_token.clone(), rule);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn check(&self, cmd: &[String]) -> Evaluation {
|
||||
let rules = match cmd.first() {
|
||||
Some(first) => match self.rules_by_program.get_vec(first) {
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
use std::any::Any;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use codex_execpolicy::Decision;
|
||||
use codex_execpolicy::Error;
|
||||
use codex_execpolicy::Evaluation;
|
||||
use codex_execpolicy::Policy;
|
||||
use codex_execpolicy::PolicyParser;
|
||||
use codex_execpolicy::RuleMatch;
|
||||
use codex_execpolicy::RuleRef;
|
||||
@@ -35,16 +39,14 @@ fn rule_snapshots(rules: &[RuleRef]) -> Vec<RuleSnapshot> {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basic_match() {
|
||||
fn basic_match() -> Result<()> {
|
||||
let policy_src = r#"
|
||||
prefix_rule(
|
||||
pattern = ["git", "status"],
|
||||
)
|
||||
"#;
|
||||
let mut parser = PolicyParser::new();
|
||||
parser
|
||||
.parse("test.codexpolicy", policy_src)
|
||||
.expect("parse policy");
|
||||
parser.parse("test.codexpolicy", policy_src)?;
|
||||
let policy = parser.build();
|
||||
let cmd = tokens(&["git", "status"]);
|
||||
let evaluation = policy.check(&cmd);
|
||||
@@ -58,10 +60,54 @@ prefix_rule(
|
||||
},
|
||||
evaluation
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_multiple_policy_files() {
|
||||
fn add_prefix_rule_extends_policy() -> Result<()> {
|
||||
let mut policy = Policy::empty();
|
||||
policy.add_prefix_rule(&tokens(&["ls", "-l"]), Decision::Prompt)?;
|
||||
|
||||
let rules = rule_snapshots(policy.rules().get_vec("ls").context("missing ls rules")?);
|
||||
assert_eq!(
|
||||
vec![RuleSnapshot::Prefix(PrefixRule {
|
||||
pattern: PrefixPattern {
|
||||
first: Arc::from("ls"),
|
||||
rest: vec![PatternToken::Single(String::from("-l"))].into(),
|
||||
},
|
||||
decision: Decision::Prompt,
|
||||
})],
|
||||
rules
|
||||
);
|
||||
|
||||
let evaluation = policy.check(&tokens(&["ls", "-l", "/tmp"]));
|
||||
assert_eq!(
|
||||
Evaluation::Match {
|
||||
decision: Decision::Prompt,
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["ls", "-l"]),
|
||||
decision: Decision::Prompt,
|
||||
}],
|
||||
},
|
||||
evaluation
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_prefix_rule_rejects_empty_prefix() -> Result<()> {
|
||||
let mut policy = Policy::empty();
|
||||
let result = policy.add_prefix_rule(&[], Decision::Allow);
|
||||
|
||||
match result.unwrap_err() {
|
||||
Error::InvalidPattern(message) => assert_eq!(message, "prefix cannot be empty"),
|
||||
other => panic!("expected InvalidPattern(..), got {other:?}"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_multiple_policy_files() -> Result<()> {
|
||||
let first_policy = r#"
|
||||
prefix_rule(
|
||||
pattern = ["git"],
|
||||
@@ -75,15 +121,11 @@ prefix_rule(
|
||||
)
|
||||
"#;
|
||||
let mut parser = PolicyParser::new();
|
||||
parser
|
||||
.parse("first.codexpolicy", first_policy)
|
||||
.expect("parse policy");
|
||||
parser
|
||||
.parse("second.codexpolicy", second_policy)
|
||||
.expect("parse policy");
|
||||
parser.parse("first.codexpolicy", first_policy)?;
|
||||
parser.parse("second.codexpolicy", second_policy)?;
|
||||
let policy = parser.build();
|
||||
|
||||
let git_rules = rule_snapshots(policy.rules().get_vec("git").expect("git rules"));
|
||||
let git_rules = rule_snapshots(policy.rules().get_vec("git").context("missing git rules")?);
|
||||
assert_eq!(
|
||||
vec![
|
||||
RuleSnapshot::Prefix(PrefixRule {
|
||||
@@ -133,23 +175,27 @@ prefix_rule(
|
||||
},
|
||||
commit_eval
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_first_token_alias_expands_to_multiple_rules() {
|
||||
fn only_first_token_alias_expands_to_multiple_rules() -> Result<()> {
|
||||
let policy_src = r#"
|
||||
prefix_rule(
|
||||
pattern = [["bash", "sh"], ["-c", "-l"]],
|
||||
)
|
||||
"#;
|
||||
let mut parser = PolicyParser::new();
|
||||
parser
|
||||
.parse("test.codexpolicy", policy_src)
|
||||
.expect("parse policy");
|
||||
parser.parse("test.codexpolicy", policy_src)?;
|
||||
let policy = parser.build();
|
||||
|
||||
let bash_rules = rule_snapshots(policy.rules().get_vec("bash").expect("bash rules"));
|
||||
let sh_rules = rule_snapshots(policy.rules().get_vec("sh").expect("sh rules"));
|
||||
let bash_rules = rule_snapshots(
|
||||
policy
|
||||
.rules()
|
||||
.get_vec("bash")
|
||||
.context("missing bash rules")?,
|
||||
);
|
||||
let sh_rules = rule_snapshots(policy.rules().get_vec("sh").context("missing sh rules")?);
|
||||
assert_eq!(
|
||||
vec![RuleSnapshot::Prefix(PrefixRule {
|
||||
pattern: PrefixPattern {
|
||||
@@ -194,22 +240,21 @@ prefix_rule(
|
||||
},
|
||||
sh_eval
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tail_aliases_are_not_cartesian_expanded() {
|
||||
fn tail_aliases_are_not_cartesian_expanded() -> Result<()> {
|
||||
let policy_src = r#"
|
||||
prefix_rule(
|
||||
pattern = ["npm", ["i", "install"], ["--legacy-peer-deps", "--no-save"]],
|
||||
)
|
||||
"#;
|
||||
let mut parser = PolicyParser::new();
|
||||
parser
|
||||
.parse("test.codexpolicy", policy_src)
|
||||
.expect("parse policy");
|
||||
parser.parse("test.codexpolicy", policy_src)?;
|
||||
let policy = parser.build();
|
||||
|
||||
let rules = rule_snapshots(policy.rules().get_vec("npm").expect("npm rules"));
|
||||
let rules = rule_snapshots(policy.rules().get_vec("npm").context("missing npm rules")?);
|
||||
assert_eq!(
|
||||
vec![RuleSnapshot::Prefix(PrefixRule {
|
||||
pattern: PrefixPattern {
|
||||
@@ -251,10 +296,11 @@ prefix_rule(
|
||||
},
|
||||
npm_install
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_and_not_match_examples_are_enforced() {
|
||||
fn match_and_not_match_examples_are_enforced() -> Result<()> {
|
||||
let policy_src = r#"
|
||||
prefix_rule(
|
||||
pattern = ["git", "status"],
|
||||
@@ -266,9 +312,7 @@ prefix_rule(
|
||||
)
|
||||
"#;
|
||||
let mut parser = PolicyParser::new();
|
||||
parser
|
||||
.parse("test.codexpolicy", policy_src)
|
||||
.expect("parse policy");
|
||||
parser.parse("test.codexpolicy", policy_src)?;
|
||||
let policy = parser.build();
|
||||
let match_eval = policy.check(&tokens(&["git", "status"]));
|
||||
assert_eq!(
|
||||
@@ -289,10 +333,11 @@ prefix_rule(
|
||||
"status",
|
||||
]));
|
||||
assert_eq!(Evaluation::NoMatch {}, no_match_eval);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strictest_decision_wins_across_matches() {
|
||||
fn strictest_decision_wins_across_matches() -> Result<()> {
|
||||
let policy_src = r#"
|
||||
prefix_rule(
|
||||
pattern = ["git"],
|
||||
@@ -304,9 +349,7 @@ prefix_rule(
|
||||
)
|
||||
"#;
|
||||
let mut parser = PolicyParser::new();
|
||||
parser
|
||||
.parse("test.codexpolicy", policy_src)
|
||||
.expect("parse policy");
|
||||
parser.parse("test.codexpolicy", policy_src)?;
|
||||
let policy = parser.build();
|
||||
|
||||
let commit = policy.check(&tokens(&["git", "commit", "-m", "hi"]));
|
||||
@@ -326,10 +369,11 @@ prefix_rule(
|
||||
},
|
||||
commit
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strictest_decision_across_multiple_commands() {
|
||||
fn strictest_decision_across_multiple_commands() -> Result<()> {
|
||||
let policy_src = r#"
|
||||
prefix_rule(
|
||||
pattern = ["git"],
|
||||
@@ -341,9 +385,7 @@ prefix_rule(
|
||||
)
|
||||
"#;
|
||||
let mut parser = PolicyParser::new();
|
||||
parser
|
||||
.parse("test.codexpolicy", policy_src)
|
||||
.expect("parse policy");
|
||||
parser.parse("test.codexpolicy", policy_src)?;
|
||||
let policy = parser.build();
|
||||
|
||||
let commands = vec![
|
||||
@@ -372,4 +414,5 @@ prefix_rule(
|
||||
},
|
||||
evaluation
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ codex-core = { path = "../core" }
|
||||
reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["rt"] }
|
||||
tracing = { version = "0.1.41", features = ["log"] }
|
||||
tracing = { version = "0.1.43", features = ["log"] }
|
||||
which = "6.0"
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -8,11 +8,10 @@ use std::time::Instant;
|
||||
|
||||
use crate::pkce::PkceCodes;
|
||||
use crate::server::ServerOptions;
|
||||
use std::io::Write;
|
||||
use std::io::{self};
|
||||
use std::io;
|
||||
|
||||
const ANSI_YELLOW: &str = "\x1b[93m";
|
||||
const ANSI_BOLD: &str = "\x1b[1m";
|
||||
const ANSI_BLUE: &str = "\x1b[94m";
|
||||
const ANSI_GRAY: &str = "\x1b[90m";
|
||||
const ANSI_RESET: &str = "\x1b[0m";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -138,14 +137,16 @@ async fn poll_for_token(
|
||||
}
|
||||
}
|
||||
|
||||
fn print_colored_warning_device_code() {
|
||||
let mut stdout = io::stdout().lock();
|
||||
let _ = write!(
|
||||
stdout,
|
||||
"{ANSI_YELLOW}{ANSI_BOLD}Only use device code authentication when browser login is not available.{ANSI_RESET}{ANSI_YELLOW}\n\
|
||||
{ANSI_BOLD}Keep the code secret; do not share it.{ANSI_RESET}{ANSI_RESET}\n\n"
|
||||
fn print_device_code_prompt(code: &str) {
|
||||
println!(
|
||||
"\nWelcome to Codex [v{ANSI_GRAY}{version}{ANSI_RESET}]\n{ANSI_GRAY}OpenAI's command-line coding agent{ANSI_RESET}\n\
|
||||
\nFollow these steps to sign in with ChatGPT using device code authorization:\n\
|
||||
\n1. Open this link in your browser\n {ANSI_BLUE}https://auth.openai.com/codex/device{ANSI_RESET}\n\
|
||||
\n2. Enter this one-time code {ANSI_GRAY}(expires in 15 minutes){ANSI_RESET}\n {ANSI_BLUE}{code}{ANSI_RESET}\n\
|
||||
\n{ANSI_GRAY}Device codes are a common phishing target. Never share this code.{ANSI_RESET}\n",
|
||||
version = env!("CARGO_PKG_VERSION"),
|
||||
code = code
|
||||
);
|
||||
let _ = stdout.flush();
|
||||
}
|
||||
|
||||
/// Full device code login flow.
|
||||
@@ -153,13 +154,9 @@ pub async fn run_device_code_login(opts: ServerOptions) -> std::io::Result<()> {
|
||||
let client = reqwest::Client::new();
|
||||
let base_url = opts.issuer.trim_end_matches('/');
|
||||
let api_base_url = format!("{}/api/accounts", opts.issuer.trim_end_matches('/'));
|
||||
print_colored_warning_device_code();
|
||||
let uc = request_user_code(&client, &api_base_url, &opts.client_id).await?;
|
||||
|
||||
println!(
|
||||
"To authenticate:\n 1. Open in your browser: {ANSI_BOLD}https://auth.openai.com/codex/device{ANSI_RESET}\n 2. Enter the one-time code below within 15 minutes:\n\n {ANSI_BOLD}{}{ANSI_RESET}\n",
|
||||
uc.user_code
|
||||
);
|
||||
print_device_code_prompt(&uc.user_code);
|
||||
|
||||
let code_resp = poll_for_token(
|
||||
&client,
|
||||
|
||||
@@ -307,6 +307,7 @@ async fn run_codex_tool_session_inner(
|
||||
| EventMsg::UndoCompleted(_)
|
||||
| EventMsg::ExitedReviewMode(_)
|
||||
| EventMsg::ContextCompacted(_)
|
||||
| EventMsg::SaveSessionResponse(_)
|
||||
| EventMsg::DeprecationNotice(_) => {
|
||||
// For now, we do not do anything extra for these
|
||||
// events. Note that
|
||||
|
||||
@@ -38,6 +38,13 @@ SERVER_NOTIFICATION_TYPE_NAMES: list[str] = []
|
||||
# order to compile without warnings.
|
||||
LARGE_ENUMS = {"ServerResult"}
|
||||
|
||||
# some types need setting a default value for `r#type`
|
||||
# ref: [#7417](https://github.com/openai/codex/pull/7417)
|
||||
default_type_values: dict[str, str] = {
|
||||
"ToolInputSchema": "object",
|
||||
"ToolOutputSchema": "object",
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
@@ -351,6 +358,14 @@ class StructField:
|
||||
out.append(f" pub {self.name}: {self.type_name},\n")
|
||||
|
||||
|
||||
def append_serde_attr(existing: str | None, fragment: str) -> str:
|
||||
if existing is None:
|
||||
return f"#[serde({fragment})]"
|
||||
assert existing.startswith("#[serde(") and existing.endswith(")]"), existing
|
||||
body = existing[len("#[serde(") : -2]
|
||||
return f"#[serde({body}, {fragment})]"
|
||||
|
||||
|
||||
def define_struct(
|
||||
name: str,
|
||||
properties: dict[str, Any],
|
||||
@@ -359,6 +374,14 @@ def define_struct(
|
||||
) -> list[str]:
|
||||
out: list[str] = []
|
||||
|
||||
type_default_fn: str | None = None
|
||||
if name in default_type_values:
|
||||
snake_name = to_snake_case(name) or name
|
||||
type_default_fn = f"{snake_name}_type_default_str"
|
||||
out.append(f"fn {type_default_fn}() -> String {{\n")
|
||||
out.append(f' "{default_type_values[name]}".to_string()\n')
|
||||
out.append("}\n\n")
|
||||
|
||||
fields: list[StructField] = []
|
||||
for prop_name, prop in properties.items():
|
||||
if prop_name == "_meta":
|
||||
@@ -380,6 +403,10 @@ def define_struct(
|
||||
if is_optional:
|
||||
prop_type = f"Option<{prop_type}>"
|
||||
rs_prop = rust_prop_name(prop_name, is_optional)
|
||||
|
||||
if prop_name == "type" and type_default_fn:
|
||||
rs_prop.serde = append_serde_attr(rs_prop.serde, f'default = "{type_default_fn}"')
|
||||
|
||||
if prop_type.startswith("&'static str"):
|
||||
fields.append(StructField("const", rs_prop.name, prop_type, rs_prop.serde, rs_prop.ts))
|
||||
else:
|
||||
|
||||
@@ -1474,6 +1474,10 @@ pub struct Tool {
|
||||
pub title: Option<String>,
|
||||
}
|
||||
|
||||
fn tool_output_schema_type_default_str() -> String {
|
||||
"object".to_string()
|
||||
}
|
||||
|
||||
/// An optional JSON Schema object defining the structure of the tool's output returned in
|
||||
/// the structuredContent field of a CallToolResult.
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)]
|
||||
@@ -1484,9 +1488,14 @@ pub struct ToolOutputSchema {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub required: Option<Vec<String>>,
|
||||
#[serde(default = "tool_output_schema_type_default_str")]
|
||||
pub r#type: String, // &'static str = "object"
|
||||
}
|
||||
|
||||
fn tool_input_schema_type_default_str() -> String {
|
||||
"object".to_string()
|
||||
}
|
||||
|
||||
/// A JSON Schema object defining the expected parameters for the tool.
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)]
|
||||
pub struct ToolInputSchema {
|
||||
@@ -1496,6 +1505,7 @@ pub struct ToolInputSchema {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub required: Option<Vec<String>>,
|
||||
#[serde(default = "tool_input_schema_type_default_str")]
|
||||
pub r#type: String, // &'static str = "object"
|
||||
}
|
||||
|
||||
|
||||
@@ -132,6 +132,7 @@ pub enum ResponseItem {
|
||||
GhostSnapshot {
|
||||
ghost_commit: GhostCommit,
|
||||
},
|
||||
#[serde(alias = "compaction")]
|
||||
CompactionSummary {
|
||||
encrypted_content: String,
|
||||
},
|
||||
@@ -537,6 +538,7 @@ mod tests {
|
||||
use anyhow::Result;
|
||||
use mcp_types::ImageContent;
|
||||
use mcp_types::TextContent;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
@@ -650,6 +652,21 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserializes_compaction_alias() -> Result<()> {
|
||||
let json = r#"{"type":"compaction","encrypted_content":"abc"}"#;
|
||||
|
||||
let item: ResponseItem = serde_json::from_str(json)?;
|
||||
|
||||
assert_eq!(
|
||||
item,
|
||||
ResponseItem::CompactionSummary {
|
||||
encrypted_content: "abc".into(),
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrips_web_search_call_actions() -> Result<()> {
|
||||
let cases = vec![
|
||||
|
||||
@@ -105,6 +105,9 @@ pub enum Op {
|
||||
final_output_json_schema: Option<Value>,
|
||||
},
|
||||
|
||||
/// Persist the current session under a user-provided name.
|
||||
SaveSession { name: String },
|
||||
|
||||
/// Override parts of the persistent turn context for subsequent turns.
|
||||
///
|
||||
/// All fields are optional; when omitted, the existing value is preserved.
|
||||
@@ -531,6 +534,9 @@ pub enum EventMsg {
|
||||
|
||||
BackgroundEvent(BackgroundEventEvent),
|
||||
|
||||
/// Result of a save-session request.
|
||||
SaveSessionResponse(SaveSessionResponseEvent),
|
||||
|
||||
UndoStarted(UndoStartedEvent),
|
||||
|
||||
UndoCompleted(UndoCompletedEvent),
|
||||
@@ -1088,6 +1094,13 @@ impl InitialHistory {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn without_session_meta(&self) -> Vec<RolloutItem> {
|
||||
self.get_rollout_items()
|
||||
.into_iter()
|
||||
.filter(|item| !matches!(item, RolloutItem::SessionMeta(_)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn get_event_msgs(&self) -> Option<Vec<EventMsg>> {
|
||||
match self {
|
||||
InitialHistory::New => None,
|
||||
@@ -1171,6 +1184,8 @@ pub struct SessionMeta {
|
||||
#[serde(default)]
|
||||
pub source: SessionSource,
|
||||
pub model_provider: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for SessionMeta {
|
||||
@@ -1184,6 +1199,7 @@ impl Default for SessionMeta {
|
||||
instructions: None,
|
||||
source: SessionSource::default(),
|
||||
model_provider: None,
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1256,13 +1272,47 @@ pub struct GitInfo {
|
||||
pub repository_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ReviewDelivery {
|
||||
Inline,
|
||||
Detached,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema, TS)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
#[ts(tag = "type")]
|
||||
pub enum ReviewTarget {
|
||||
/// Review the working tree: staged, unstaged, and untracked files.
|
||||
UncommittedChanges,
|
||||
|
||||
/// Review changes between the current branch and the given base branch.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
BaseBranch { branch: String },
|
||||
|
||||
/// Review the changes introduced by a specific commit.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
Commit {
|
||||
sha: String,
|
||||
/// Optional human-readable label (e.g., commit subject) for UIs.
|
||||
title: Option<String>,
|
||||
},
|
||||
|
||||
/// Arbitrary instructions provided by the user.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
Custom { instructions: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
|
||||
/// Review request sent to the review session.
|
||||
pub struct ReviewRequest {
|
||||
pub prompt: String,
|
||||
pub user_facing_hint: String,
|
||||
#[serde(default)]
|
||||
pub append_to_original_thread: bool,
|
||||
pub target: ReviewTarget,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub user_facing_hint: Option<String>,
|
||||
}
|
||||
|
||||
/// Structured review result produced by a child review session.
|
||||
@@ -1328,6 +1378,10 @@ impl Default for ExecCommandSource {
|
||||
pub struct ExecCommandBeginEvent {
|
||||
/// Identifier so this can be paired with the ExecCommandEnd event.
|
||||
pub call_id: String,
|
||||
/// Identifier for the underlying PTY process (when available).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub process_id: Option<String>,
|
||||
/// Turn ID that this command belongs to.
|
||||
pub turn_id: String,
|
||||
/// The command to be executed.
|
||||
@@ -1348,6 +1402,10 @@ pub struct ExecCommandBeginEvent {
|
||||
pub struct ExecCommandEndEvent {
|
||||
/// Identifier for the ExecCommandBegin that finished.
|
||||
pub call_id: String,
|
||||
/// Identifier for the underlying PTY process (when available).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub process_id: Option<String>,
|
||||
/// Turn ID that this command belongs to.
|
||||
pub turn_id: String,
|
||||
/// The command that was executed.
|
||||
@@ -1447,6 +1505,13 @@ pub struct StreamInfoEvent {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
|
||||
pub struct SaveSessionResponseEvent {
|
||||
pub name: String,
|
||||
pub rollout_path: PathBuf,
|
||||
pub conversation_id: ConversationId,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
|
||||
pub struct PatchApplyBeginEvent {
|
||||
/// Identifier so this can be paired with the PatchApplyEnd event.
|
||||
|
||||
@@ -91,6 +91,7 @@ unicode-width = { workspace = true }
|
||||
url = { workspace = true }
|
||||
|
||||
codex-windows-sandbox = { workspace = true }
|
||||
tokio-util = { workspace = true, features = ["time"] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = { workspace = true }
|
||||
|
||||
@@ -7,6 +7,8 @@ use crate::diff_render::DiffSummary;
|
||||
use crate::exec_command::strip_bash_lc_and_escape;
|
||||
use crate::file_search::FileSearchManager;
|
||||
use crate::history_cell::HistoryCell;
|
||||
#[cfg(not(debug_assertions))]
|
||||
use crate::history_cell::UpdateAvailableHistoryCell;
|
||||
use crate::model_migration::ModelMigrationOutcome;
|
||||
use crate::model_migration::migration_copy_for_config;
|
||||
use crate::model_migration::run_model_migration_prompt;
|
||||
@@ -14,6 +16,8 @@ use crate::pager_overlay::Overlay;
|
||||
use crate::render::highlight::highlight_bash_to_lines;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::resume_picker::ResumeSelection;
|
||||
use crate::skill_error_prompt::SkillErrorPromptOutcome;
|
||||
use crate::skill_error_prompt::run_skill_error_prompt;
|
||||
use crate::tui;
|
||||
use crate::tui::TuiEvent;
|
||||
use crate::update_action::UpdateAction;
|
||||
@@ -36,6 +40,7 @@ use codex_core::protocol::Op;
|
||||
use codex_core::protocol::SessionSource;
|
||||
use codex_core::protocol::TokenUsage;
|
||||
use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig;
|
||||
use codex_core::skills::load_skills;
|
||||
use codex_protocol::ConversationId;
|
||||
use color_eyre::eyre::Result;
|
||||
use color_eyre::eyre::WrapErr;
|
||||
@@ -55,9 +60,6 @@ use std::time::Duration;
|
||||
use tokio::select;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
use crate::history_cell::UpdateAvailableHistoryCell;
|
||||
|
||||
const GPT_5_1_MIGRATION_AUTH_MODES: [AuthMode; 2] = [AuthMode::ChatGPT, AuthMode::ApiKey];
|
||||
const GPT_5_1_CODEX_MIGRATION_AUTH_MODES: [AuthMode; 1] = [AuthMode::ChatGPT];
|
||||
|
||||
@@ -249,6 +251,7 @@ impl App {
|
||||
initial_images: Vec<PathBuf>,
|
||||
resume_selection: ResumeSelection,
|
||||
feedback: codex_feedback::CodexFeedback,
|
||||
is_first_run: bool,
|
||||
) -> Result<AppExitInfo> {
|
||||
use tokio_stream::StreamExt;
|
||||
let (app_event_tx, mut app_event_rx) = unbounded_channel();
|
||||
@@ -267,6 +270,20 @@ impl App {
|
||||
SessionSource::Cli,
|
||||
));
|
||||
|
||||
let skills_outcome = load_skills(&config);
|
||||
if !skills_outcome.errors.is_empty() {
|
||||
match run_skill_error_prompt(tui, &skills_outcome.errors).await {
|
||||
SkillErrorPromptOutcome::Exit => {
|
||||
return Ok(AppExitInfo {
|
||||
token_usage: TokenUsage::default(),
|
||||
conversation_id: None,
|
||||
update_action: None,
|
||||
});
|
||||
}
|
||||
SkillErrorPromptOutcome::Continue => {}
|
||||
}
|
||||
}
|
||||
|
||||
let enhanced_keys_supported = tui.enhanced_keys_supported();
|
||||
|
||||
let mut chat_widget = match resume_selection {
|
||||
@@ -280,6 +297,7 @@ impl App {
|
||||
enhanced_keys_supported,
|
||||
auth_manager: auth_manager.clone(),
|
||||
feedback: feedback.clone(),
|
||||
is_first_run,
|
||||
};
|
||||
ChatWidget::new(init, conversation_manager.clone())
|
||||
}
|
||||
@@ -303,6 +321,29 @@ impl App {
|
||||
enhanced_keys_supported,
|
||||
auth_manager: auth_manager.clone(),
|
||||
feedback: feedback.clone(),
|
||||
is_first_run,
|
||||
};
|
||||
ChatWidget::new_from_existing(
|
||||
init,
|
||||
resumed.conversation,
|
||||
resumed.session_configured,
|
||||
)
|
||||
}
|
||||
ResumeSelection::Fork(path) => {
|
||||
let resumed = conversation_manager
|
||||
.fork_from_rollout(config.clone(), path.clone(), auth_manager.clone())
|
||||
.await
|
||||
.wrap_err_with(|| format!("Failed to fork session from {}", path.display()))?;
|
||||
let init = crate::chatwidget::ChatWidgetInit {
|
||||
config: config.clone(),
|
||||
frame_requester: tui.frame_requester(),
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
initial_prompt: initial_prompt.clone(),
|
||||
initial_images: initial_images.clone(),
|
||||
enhanced_keys_supported,
|
||||
auth_manager: auth_manager.clone(),
|
||||
feedback: feedback.clone(),
|
||||
is_first_run: false,
|
||||
};
|
||||
ChatWidget::new_from_existing(
|
||||
init,
|
||||
@@ -456,6 +497,7 @@ impl App {
|
||||
enhanced_keys_supported: self.enhanced_keys_supported,
|
||||
auth_manager: self.auth_manager.clone(),
|
||||
feedback: self.feedback.clone(),
|
||||
is_first_run: false,
|
||||
};
|
||||
self.chat_widget = ChatWidget::new(init, self.server.clone());
|
||||
if let Some(summary) = summary {
|
||||
|
||||
@@ -347,6 +347,7 @@ impl App {
|
||||
enhanced_keys_supported: self.enhanced_keys_supported,
|
||||
auth_manager: self.auth_manager.clone(),
|
||||
feedback: self.feedback.clone(),
|
||||
is_first_run: false,
|
||||
};
|
||||
self.chat_widget =
|
||||
crate::chatwidget::ChatWidget::new_from_existing(init, conv, session_configured);
|
||||
|
||||
@@ -69,7 +69,10 @@ const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000;
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum InputResult {
|
||||
Submitted(String),
|
||||
Command(SlashCommand),
|
||||
Command {
|
||||
command: SlashCommand,
|
||||
args: Option<String>,
|
||||
},
|
||||
None,
|
||||
}
|
||||
|
||||
@@ -101,6 +104,7 @@ pub(crate) struct ChatComposer {
|
||||
dismissed_file_popup_token: Option<String>,
|
||||
current_file_query: Option<String>,
|
||||
pending_pastes: Vec<(String, String)>,
|
||||
large_paste_counters: HashMap<usize, usize>,
|
||||
has_focus: bool,
|
||||
attached_images: Vec<AttachedImage>,
|
||||
placeholder_text: String,
|
||||
@@ -113,6 +117,7 @@ pub(crate) struct ChatComposer {
|
||||
footer_mode: FooterMode,
|
||||
footer_hint_override: Option<Vec<(String, String)>>,
|
||||
context_window_percent: Option<i64>,
|
||||
context_window_used_tokens: Option<i64>,
|
||||
}
|
||||
|
||||
/// Popup state – at most one can be visible at any time.
|
||||
@@ -146,6 +151,7 @@ impl ChatComposer {
|
||||
dismissed_file_popup_token: None,
|
||||
current_file_query: None,
|
||||
pending_pastes: Vec::new(),
|
||||
large_paste_counters: HashMap::new(),
|
||||
has_focus: has_input_focus,
|
||||
attached_images: Vec::new(),
|
||||
placeholder_text,
|
||||
@@ -156,6 +162,7 @@ impl ChatComposer {
|
||||
footer_mode: FooterMode::ShortcutSummary,
|
||||
footer_hint_override: None,
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
};
|
||||
// Apply configuration via the setter to keep side-effects centralized.
|
||||
this.set_disable_paste_burst(disable_paste_burst);
|
||||
@@ -220,7 +227,7 @@ impl ChatComposer {
|
||||
pub fn handle_paste(&mut self, pasted: String) -> bool {
|
||||
let char_count = pasted.chars().count();
|
||||
if char_count > LARGE_PASTE_CHAR_THRESHOLD {
|
||||
let placeholder = format!("[Pasted Content {char_count} chars]");
|
||||
let placeholder = self.next_large_paste_placeholder(char_count);
|
||||
self.textarea.insert_element(&placeholder);
|
||||
self.pending_pastes.push((placeholder, pasted));
|
||||
} else if char_count > 1 && self.handle_paste_image_path(pasted.clone()) {
|
||||
@@ -333,6 +340,19 @@ impl ChatComposer {
|
||||
PasteBurst::recommended_flush_delay()
|
||||
}
|
||||
|
||||
fn command_args_from_line(line: &str, command: SlashCommand) -> Option<String> {
|
||||
if let Some((name, rest)) = parse_slash_name(line)
|
||||
&& name == command.command()
|
||||
{
|
||||
let trimmed = rest.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Integrate results from an asynchronous file search.
|
||||
pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec<FileMatch>) {
|
||||
// Only apply if user is still editing a token starting with `query`.
|
||||
@@ -360,6 +380,17 @@ impl ChatComposer {
|
||||
self.set_has_focus(has_focus);
|
||||
}
|
||||
|
||||
fn next_large_paste_placeholder(&mut self, char_count: usize) -> String {
|
||||
let base = format!("[Pasted Content {char_count} chars]");
|
||||
let next_suffix = self.large_paste_counters.entry(char_count).or_insert(0);
|
||||
*next_suffix += 1;
|
||||
if *next_suffix == 1 {
|
||||
base
|
||||
} else {
|
||||
format!("{base} #{next_suffix}")
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn insert_str(&mut self, text: &str) {
|
||||
self.textarea.insert_str(text);
|
||||
self.sync_command_popup();
|
||||
@@ -505,8 +536,9 @@ impl ChatComposer {
|
||||
if let Some(sel) = popup.selected_item() {
|
||||
match sel {
|
||||
CommandItem::Builtin(cmd) => {
|
||||
let args = Self::command_args_from_line(first_line, cmd);
|
||||
self.textarea.set_text("");
|
||||
return (InputResult::Command(cmd), true);
|
||||
return (InputResult::Command { command: cmd, args }, true);
|
||||
}
|
||||
CommandItem::UserPrompt(idx) => {
|
||||
if let Some(prompt) = popup.prompt(idx) {
|
||||
@@ -920,22 +952,21 @@ impl ChatComposer {
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => {
|
||||
// If the first line is a bare built-in slash command (no args),
|
||||
// dispatch it even when the slash popup isn't visible. This preserves
|
||||
// the workflow: type a prefix ("/di"), press Tab to complete to
|
||||
// "/diff ", then press Enter to run it. Tab moves the cursor beyond
|
||||
// the '/name' token and our caret-based heuristic hides the popup,
|
||||
// but Enter should still dispatch the command rather than submit
|
||||
// literal text.
|
||||
// If the first line is a built-in slash command, dispatch it even when
|
||||
// the slash popup isn't visible. This preserves the workflow:
|
||||
// type a prefix ("/di"), press Tab to complete to "/diff ", then press
|
||||
// Enter to run it. Tab moves the cursor beyond the '/name' token and
|
||||
// our caret-based heuristic hides the popup, but Enter should still
|
||||
// dispatch the command rather than submit literal text.
|
||||
let first_line = self.textarea.text().lines().next().unwrap_or("");
|
||||
if let Some((name, rest)) = parse_slash_name(first_line)
|
||||
&& rest.is_empty()
|
||||
if let Some((name, _rest)) = parse_slash_name(first_line)
|
||||
&& let Some((_n, cmd)) = built_in_slash_commands()
|
||||
.into_iter()
|
||||
.find(|(n, _)| *n == name)
|
||||
{
|
||||
let args = Self::command_args_from_line(first_line, cmd);
|
||||
self.textarea.set_text("");
|
||||
return (InputResult::Command(cmd), true);
|
||||
return (InputResult::Command { command: cmd, args }, true);
|
||||
}
|
||||
// If we're in a paste-like burst capture, treat Enter as part of the burst
|
||||
// and accumulate it rather than submitting or inserting immediately.
|
||||
@@ -1387,6 +1418,7 @@ impl ChatComposer {
|
||||
use_shift_enter_hint: self.use_shift_enter_hint,
|
||||
is_task_running: self.is_task_running,
|
||||
context_window_percent: self.context_window_percent,
|
||||
context_window_used_tokens: self.context_window_used_tokens,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1517,10 +1549,13 @@ impl ChatComposer {
|
||||
self.is_task_running = running;
|
||||
}
|
||||
|
||||
pub(crate) fn set_context_window_percent(&mut self, percent: Option<i64>) {
|
||||
if self.context_window_percent != percent {
|
||||
self.context_window_percent = percent;
|
||||
pub(crate) fn set_context_window(&mut self, percent: Option<i64>, used_tokens: Option<i64>) {
|
||||
if self.context_window_percent == percent && self.context_window_used_tokens == used_tokens
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.context_window_percent = percent;
|
||||
self.context_window_used_tokens = used_tokens;
|
||||
}
|
||||
|
||||
pub(crate) fn set_esc_backtrack_hint(&mut self, show: bool) {
|
||||
@@ -2395,8 +2430,9 @@ mod tests {
|
||||
// When a slash command is dispatched, the composer should return a
|
||||
// Command result (not submit literal text) and clear its textarea.
|
||||
match result {
|
||||
InputResult::Command(cmd) => {
|
||||
InputResult::Command { command: cmd, args } => {
|
||||
assert_eq!(cmd.command(), "init");
|
||||
assert!(args.is_none());
|
||||
}
|
||||
InputResult::Submitted(text) => {
|
||||
panic!("expected command dispatch, but composer submitted literal text: {text}")
|
||||
@@ -2470,7 +2506,10 @@ mod tests {
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
match result {
|
||||
InputResult::Command(cmd) => assert_eq!(cmd.command(), "diff"),
|
||||
InputResult::Command { command: cmd, args } => {
|
||||
assert_eq!(cmd.command(), "diff");
|
||||
assert!(args.is_none());
|
||||
}
|
||||
InputResult::Submitted(text) => {
|
||||
panic!("expected command dispatch after Tab completion, got literal submit: {text}")
|
||||
}
|
||||
@@ -2479,6 +2518,42 @@ mod tests {
|
||||
assert!(composer.textarea.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_command_with_args_dispatches_and_preserves_args() {
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
|
||||
composer.textarea.set_text("/save feature-one");
|
||||
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
match result {
|
||||
InputResult::Command { command: cmd, args } => {
|
||||
assert_eq!(cmd, SlashCommand::Save);
|
||||
assert_eq!(args.as_deref(), Some("feature-one"));
|
||||
}
|
||||
InputResult::Submitted(text) => {
|
||||
panic!(
|
||||
"expected slash command dispatch, but composer submitted literal text: {text}"
|
||||
)
|
||||
}
|
||||
InputResult::None => panic!("expected Command result for '/save feature-one'"),
|
||||
}
|
||||
assert!(composer.textarea.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_mention_dispatches_command_and_inserts_at() {
|
||||
use crossterm::event::KeyCode;
|
||||
@@ -2501,8 +2576,9 @@ mod tests {
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
match result {
|
||||
InputResult::Command(cmd) => {
|
||||
InputResult::Command { command: cmd, args } => {
|
||||
assert_eq!(cmd.command(), "mention");
|
||||
assert!(args.is_none());
|
||||
}
|
||||
InputResult::Submitted(text) => {
|
||||
panic!("expected command dispatch, but composer submitted literal text: {text}")
|
||||
@@ -2665,6 +2741,83 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_duplicate_length_pastes_removes_only_target() {
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
|
||||
let paste = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4);
|
||||
let placeholder_base = format!("[Pasted Content {} chars]", paste.chars().count());
|
||||
let placeholder_second = format!("{placeholder_base} #2");
|
||||
|
||||
composer.handle_paste(paste.clone());
|
||||
composer.handle_paste(paste.clone());
|
||||
assert_eq!(
|
||||
composer.textarea.text(),
|
||||
format!("{placeholder_base}{placeholder_second}")
|
||||
);
|
||||
assert_eq!(composer.pending_pastes.len(), 2);
|
||||
|
||||
composer.textarea.set_cursor(composer.textarea.text().len());
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(composer.textarea.text(), placeholder_base);
|
||||
assert_eq!(composer.pending_pastes.len(), 1);
|
||||
assert_eq!(composer.pending_pastes[0].0, placeholder_base);
|
||||
assert_eq!(composer.pending_pastes[0].1, paste);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_paste_numbering_does_not_reuse_after_deletion() {
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
|
||||
let paste = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4);
|
||||
let base = format!("[Pasted Content {} chars]", paste.chars().count());
|
||||
let second = format!("{base} #2");
|
||||
let third = format!("{base} #3");
|
||||
|
||||
composer.handle_paste(paste.clone());
|
||||
composer.handle_paste(paste.clone());
|
||||
assert_eq!(composer.textarea.text(), format!("{base}{second}"));
|
||||
|
||||
composer.textarea.set_cursor(base.len());
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||||
assert_eq!(composer.textarea.text(), second);
|
||||
assert_eq!(composer.pending_pastes.len(), 1);
|
||||
assert_eq!(composer.pending_pastes[0].0, second);
|
||||
|
||||
composer.textarea.set_cursor(composer.textarea.text().len());
|
||||
composer.handle_paste(paste);
|
||||
|
||||
assert_eq!(composer.textarea.text(), format!("{second}{third}"));
|
||||
assert_eq!(composer.pending_pastes.len(), 2);
|
||||
assert_eq!(composer.pending_pastes[0].0, second);
|
||||
assert_eq!(composer.pending_pastes[1].0, third);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_partial_placeholder_deletion() {
|
||||
use crossterm::event::KeyCode;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::key_hint;
|
||||
use crate::key_hint::KeyBinding;
|
||||
use crate::render::line_utils::prefix_lines;
|
||||
use crate::status::format_tokens_compact;
|
||||
use crate::ui_consts::FOOTER_INDENT_COLS;
|
||||
use crossterm::event::KeyCode;
|
||||
use ratatui::buffer::Buffer;
|
||||
@@ -18,6 +19,7 @@ pub(crate) struct FooterProps {
|
||||
pub(crate) use_shift_enter_hint: bool,
|
||||
pub(crate) is_task_running: bool,
|
||||
pub(crate) context_window_percent: Option<i64>,
|
||||
pub(crate) context_window_used_tokens: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -81,7 +83,10 @@ fn footer_lines(props: FooterProps) -> Vec<Line<'static>> {
|
||||
is_task_running: props.is_task_running,
|
||||
})],
|
||||
FooterMode::ShortcutSummary => {
|
||||
let mut line = context_window_line(props.context_window_percent);
|
||||
let mut line = context_window_line(
|
||||
props.context_window_percent,
|
||||
props.context_window_used_tokens,
|
||||
);
|
||||
line.push_span(" · ".dim());
|
||||
line.extend(vec![
|
||||
key_hint::plain(KeyCode::Char('?')).into(),
|
||||
@@ -94,7 +99,10 @@ fn footer_lines(props: FooterProps) -> Vec<Line<'static>> {
|
||||
esc_backtrack_hint: props.esc_backtrack_hint,
|
||||
}),
|
||||
FooterMode::EscHint => vec![esc_hint_line(props.esc_backtrack_hint)],
|
||||
FooterMode::ContextOnly => vec![context_window_line(props.context_window_percent)],
|
||||
FooterMode::ContextOnly => vec![context_window_line(
|
||||
props.context_window_percent,
|
||||
props.context_window_used_tokens,
|
||||
)],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,9 +229,18 @@ fn build_columns(entries: Vec<Line<'static>>) -> Vec<Line<'static>> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn context_window_line(percent: Option<i64>) -> Line<'static> {
|
||||
let percent = percent.unwrap_or(100).clamp(0, 100);
|
||||
Line::from(vec![Span::from(format!("{percent}% context left")).dim()])
|
||||
fn context_window_line(percent: Option<i64>, used_tokens: Option<i64>) -> Line<'static> {
|
||||
if let Some(percent) = percent {
|
||||
let percent = percent.clamp(0, 100);
|
||||
return Line::from(vec![Span::from(format!("{percent}% context left")).dim()]);
|
||||
}
|
||||
|
||||
if let Some(tokens) = used_tokens {
|
||||
let used_fmt = format_tokens_compact(tokens);
|
||||
return Line::from(vec![Span::from(format!("{used_fmt} used")).dim()]);
|
||||
}
|
||||
|
||||
Line::from(vec![Span::from("100% context left").dim()])
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -400,6 +417,7 @@ mod tests {
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: false,
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -411,6 +429,7 @@ mod tests {
|
||||
use_shift_enter_hint: true,
|
||||
is_task_running: false,
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -422,6 +441,7 @@ mod tests {
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: false,
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -433,6 +453,7 @@ mod tests {
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: true,
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -444,6 +465,7 @@ mod tests {
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: false,
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -455,6 +477,7 @@ mod tests {
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: false,
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -466,6 +489,19 @@ mod tests {
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: true,
|
||||
context_window_percent: Some(72),
|
||||
context_window_used_tokens: None,
|
||||
},
|
||||
);
|
||||
|
||||
snapshot_footer(
|
||||
"footer_context_tokens_used",
|
||||
FooterProps {
|
||||
mode: FooterMode::ShortcutSummary,
|
||||
esc_backtrack_hint: false,
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: false,
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: Some(123_456),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ pub(crate) struct BottomPane {
|
||||
/// Queued user messages to show above the composer while a turn is running.
|
||||
queued_user_messages: QueuedUserMessages,
|
||||
context_window_percent: Option<i64>,
|
||||
context_window_used_tokens: Option<i64>,
|
||||
}
|
||||
|
||||
pub(crate) struct BottomPaneParams {
|
||||
@@ -118,6 +119,7 @@ impl BottomPane {
|
||||
esc_backtrack_hint: false,
|
||||
animations_enabled,
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,6 +132,11 @@ impl BottomPane {
|
||||
self.context_window_percent
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn context_window_used_tokens(&self) -> Option<i64> {
|
||||
self.context_window_used_tokens
|
||||
}
|
||||
|
||||
fn active_view(&self) -> Option<&dyn BottomPaneView> {
|
||||
self.view_stack.last().map(std::convert::AsRef::as_ref)
|
||||
}
|
||||
@@ -344,13 +351,16 @@ impl BottomPane {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_context_window_percent(&mut self, percent: Option<i64>) {
|
||||
if self.context_window_percent == percent {
|
||||
pub(crate) fn set_context_window(&mut self, percent: Option<i64>, used_tokens: Option<i64>) {
|
||||
if self.context_window_percent == percent && self.context_window_used_tokens == used_tokens
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
self.context_window_percent = percent;
|
||||
self.composer.set_context_window_percent(percent);
|
||||
self.context_window_used_tokens = used_tokens;
|
||||
self.composer
|
||||
.set_context_window(percent, self.context_window_used_tokens);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" 123K used · ? for shortcuts "
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user