diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 2d137de6ff..5d6d3f7ca0 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -494,9 +494,10 @@ jobs: --package codex-responses-api-proxy \ --package codex-sdk - - name: Stage macOS and Linux installer script + - name: Stage installer scripts run: | cp scripts/install/install.sh dist/install.sh + cp scripts/install/install.ps1 dist/install.ps1 - name: Create GitHub Release uses: softprops/action-gh-release@v2 diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index d3dfd0346e..9b459b9941 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -675,6 +675,7 @@ "bytes_1.11.1": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"require-cas\"],\"name\":\"extra-platforms\",\"optional\":true,\"package\":\"portable-atomic\",\"req\":\"^1.3\"},{\"kind\":\"dev\",\"name\":\"loom\",\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.60\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "bytestring_1.5.0": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"ahash\",\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"bytes\",\"req\":\"^1.2\"},{\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1\"}],\"features\":{\"serde\":[\"dep:serde_core\"]}}", "bzip2-sys_0.1.13+1.0.8": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.9\"}],\"features\":{\"__disabled\":[],\"static\":[]}}", + "bzip2_0.4.4": "{\"dependencies\":[{\"name\":\"bzip2-sys\",\"req\":\"^0.1.11\"},{\"name\":\"futures\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"features\":[\"quickcheck\"],\"kind\":\"dev\",\"name\":\"partial-io\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"quickcheck6\",\"package\":\"quickcheck\",\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"tokio-core\",\"req\":\"^0.1\"},{\"name\":\"tokio-io\",\"optional\":true,\"req\":\"^0.1\"}],\"features\":{\"static\":[\"bzip2-sys/static\"],\"tokio\":[\"tokio-io\",\"futures\"]}}", "bzip2_0.5.2": "{\"dependencies\":[{\"name\":\"bzip2-sys\",\"optional\":true,\"req\":\"^0.1.13\"},{\"default_features\":false,\"features\":[\"rust-allocator\",\"semver-prefix\"],\"name\":\"libbz2-rs-sys\",\"optional\":true,\"req\":\"^0.1.3\"},{\"features\":[\"quickcheck1\"],\"kind\":\"dev\",\"name\":\"partial-io\",\"req\":\"^0.5.4\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"}],\"features\":{\"default\":[\"dep:bzip2-sys\"],\"libbz2-rs-sys\":[\"dep:libbz2-rs-sys\",\"bzip2-sys?/__disabled\"],\"static\":[\"bzip2-sys?/static\"]}}", "cached_0.56.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"ahash\",\"optional\":true,\"req\":\"^0.8\"},{\"features\":[\"attributes\"],\"kind\":\"dev\",\"name\":\"async-std\",\"req\":\"^1.6\"},{\"name\":\"async-trait\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"cached_proc_macro\",\"optional\":true,\"req\":\"^0.25.0\"},{\"name\":\"cached_proc_macro_types\",\"optional\":true,\"req\":\"^0.1.1\"},{\"kind\":\"dev\",\"name\":\"copy_dir\",\"req\":\"^0.1.3\"},{\"name\":\"directories\",\"optional\":true,\"req\":\"^6.0\"},{\"default_features\":false,\"name\":\"futures\",\"optional\":true,\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"googletest\",\"req\":\"^0.11.0\"},{\"default_features\":false,\"features\":[\"inline-more\"],\"name\":\"hashbrown\",\"req\":\"^0.15\"},{\"name\":\"once_cell\",\"req\":\"^1\"},{\"name\":\"r2d2\",\"optional\":true,\"req\":\"^0.8\"},{\"features\":[\"r2d2\"],\"name\":\"redis\",\"optional\":true,\"req\":\"^0.32\"},{\"name\":\"rmp-serde\",\"optional\":true,\"req\":\"^1.1\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^3\"},{\"name\":\"sled\",\"optional\":true,\"req\":\"^0.34\"},{\"kind\":\"dev\",\"name\":\"smartstring\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.10.1\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"macros\",\"time\",\"sync\",\"parking_lot\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"web-time\",\"req\":\"^1.1.0\"}],\"features\":{\"ahash\":[\"dep:ahash\",\"hashbrown/default\"],\"async\":[\"futures\",\"tokio\",\"async-trait\"],\"async_tokio_rt_multi_thread\":[\"async\",\"tokio/rt-multi-thread\"],\"default\":[\"proc_macro\",\"ahash\"],\"disk_store\":[\"sled\",\"serde\",\"rmp-serde\",\"directories\"],\"proc_macro\":[\"cached_proc_macro\",\"cached_proc_macro_types\"],\"redis_ahash\":[\"redis_store\",\"redis/ahash\"],\"redis_async_std\":[\"redis_store\",\"async\",\"redis/aio\",\"redis/async-std-comp\",\"redis/tls\",\"redis/async-std-tls-comp\"],\"redis_connection_manager\":[\"redis_store\",\"redis/connection-manager\"],\"redis_store\":[\"redis\",\"r2d2\",\"serde\",\"serde_json\"],\"redis_tokio\":[\"redis_store\",\"async\",\"redis/aio\",\"redis/tokio-comp\",\"redis/tls\",\"redis/tokio-native-tls-comp\"],\"wasm\":[]}}", "cached_proc_macro_0.25.0": "{\"dependencies\":[{\"name\":\"darling\",\"req\":\"^0.20.8\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.49\"},{\"name\":\"quote\",\"req\":\"^1.0.6\"},{\"name\":\"syn\",\"req\":\"^2.0.52\"}],\"features\":{}}", @@ -715,6 +716,7 @@ "const-oid_0.9.6": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.2\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.3\"}],\"features\":{\"db\":[],\"std\":[]}}", "const_format_0.2.35": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"arrayvec\",\"req\":\"^0.7.0\"},{\"name\":\"const_format_proc_macros\",\"req\":\"=0.2.34\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"fastrand\",\"req\":\"^1.3.5\"},{\"default_features\":false,\"name\":\"konst\",\"optional\":true,\"req\":\"^0.2.13\"}],\"features\":{\"__debug\":[\"const_format_proc_macros/debug\"],\"__docsrs\":[],\"__inline_const_pat_tests\":[\"__test\",\"fmt\"],\"__only_new_tests\":[\"__test\"],\"__test\":[],\"all\":[\"fmt\",\"derive\",\"rust_1_64\",\"assert\"],\"assert\":[\"assertc\"],\"assertc\":[\"fmt\",\"assertcp\"],\"assertcp\":[\"rust_1_51\"],\"const_generics\":[\"rust_1_51\"],\"constant_time_as_str\":[\"fmt\"],\"default\":[],\"derive\":[\"fmt\",\"const_format_proc_macros/derive\"],\"fmt\":[\"rust_1_83\"],\"more_str_macros\":[\"rust_1_64\"],\"nightly_const_generics\":[\"const_generics\"],\"rust_1_51\":[],\"rust_1_64\":[\"rust_1_51\",\"konst\",\"konst/rust_1_64\"],\"rust_1_83\":[\"rust_1_64\"]}}", "const_format_proc_macros_0.2.34": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"fastrand\",\"req\":\"^1.3.4\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.19\"},{\"name\":\"quote\",\"req\":\"^1.0.7\"},{\"default_features\":false,\"features\":[\"parsing\",\"proc-macro\"],\"name\":\"syn\",\"optional\":true,\"req\":\"^1.0.38\"},{\"name\":\"unicode-xid\",\"req\":\"^0.2\"}],\"features\":{\"all\":[\"derive\"],\"debug\":[\"syn/extra-traits\"],\"default\":[],\"derive\":[\"syn\",\"syn/derive\",\"syn/printing\"]}}", + "constant_time_eq_0.1.5": "{\"dependencies\":[],\"features\":{}}", "constant_time_eq_0.3.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"count_instructions\",\"req\":\"^0.1.3\"},{\"features\":[\"cargo_bench_support\",\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"}],\"features\":{\"count_instructions_test\":[]}}", "convert_case_0.10.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"name\":\"unicode-segmentation\",\"req\":\"^1.9.0\"}],\"features\":{}}", "convert_case_0.6.0": "{\"dependencies\":[{\"name\":\"rand\",\"optional\":true,\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"strum\",\"req\":\"^0.18.0\"},{\"kind\":\"dev\",\"name\":\"strum_macros\",\"req\":\"^0.18.0\"},{\"name\":\"unicode-segmentation\",\"req\":\"^1.9.0\"}],\"features\":{\"random\":[\"rand\"]}}", @@ -738,6 +740,8 @@ "crossterm_winapi_0.9.1": "{\"dependencies\":[{\"features\":[\"winbase\",\"consoleapi\",\"processenv\",\"handleapi\",\"synchapi\",\"impl-default\"],\"name\":\"winapi\",\"req\":\"^0.3.8\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "crunchy_0.2.4": "{\"dependencies\":[],\"features\":{\"default\":[\"limit_128\"],\"limit_1024\":[],\"limit_128\":[],\"limit_2048\":[],\"limit_256\":[],\"limit_512\":[],\"limit_64\":[],\"std\":[]}}", "crypto-common_0.1.7": "{\"dependencies\":[{\"features\":[\"more_lengths\"],\"name\":\"generic-array\",\"req\":\"=0.14.7\"},{\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"typenum\",\"req\":\"^1.14\"}],\"features\":{\"getrandom\":[\"rand_core/getrandom\"],\"std\":[]}}", + "cssparser-macros_0.6.1": "{\"dependencies\":[{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"full\",\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{}}", + "cssparser_0.31.2": "{\"dependencies\":[{\"name\":\"cssparser-macros\",\"req\":\"^0.6.1\"},{\"kind\":\"dev\",\"name\":\"difference\",\"req\":\"^2.0\"},{\"name\":\"dtoa-short\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"encoding_rs\",\"req\":\"^0.8\"},{\"name\":\"itoa\",\"req\":\"^1.0\"},{\"features\":[\"macros\"],\"name\":\"phf\",\"req\":\">=0.8, <=0.11\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"smallvec\",\"req\":\"^1.0\"}],\"features\":{\"bench\":[],\"dummy_match_byte\":[]}}", "csv-core_0.1.13": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"arrayvec\",\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2\"}],\"features\":{\"default\":[],\"libc\":[\"memchr/libc\"]}}", "csv_1.4.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"alloc\",\"serde\"],\"kind\":\"dev\",\"name\":\"bstr\",\"req\":\"^1.7.0\"},{\"name\":\"csv-core\",\"req\":\"^0.1.11\"},{\"name\":\"itoa\",\"req\":\"^1\"},{\"name\":\"ryu\",\"req\":\"^1\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.221\"},{\"name\":\"serde_core\",\"req\":\"^1.0.221\"}],\"features\":{}}", "ctor-proc-macro_0.0.7": "{\"dependencies\":[],\"features\":{\"default\":[]}}", @@ -770,6 +774,7 @@ "derive_arbitrary_1.4.2": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"features\":[\"derive\",\"parsing\",\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{}}", "derive_more-impl_1.0.0": "{\"dependencies\":[{\"name\":\"convert_case\",\"optional\":true,\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.13.0\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"kind\":\"build\",\"name\":\"rustc_version\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"syn\",\"req\":\"^2.0.45\"},{\"name\":\"unicode-xid\",\"optional\":true,\"req\":\"^0.2.2\"}],\"features\":{\"add\":[],\"add_assign\":[],\"as_ref\":[\"syn/extra-traits\",\"syn/visit\"],\"constructor\":[],\"debug\":[\"syn/extra-traits\",\"dep:unicode-xid\"],\"default\":[],\"deref\":[],\"deref_mut\":[],\"display\":[\"syn/extra-traits\",\"dep:unicode-xid\"],\"error\":[\"syn/extra-traits\"],\"from\":[\"syn/extra-traits\"],\"from_str\":[],\"full\":[\"add\",\"add_assign\",\"as_ref\",\"constructor\",\"debug\",\"deref\",\"deref_mut\",\"display\",\"error\",\"from\",\"from_str\",\"index\",\"index_mut\",\"into\",\"into_iterator\",\"is_variant\",\"mul\",\"mul_assign\",\"not\",\"sum\",\"try_from\",\"try_into\",\"try_unwrap\",\"unwrap\"],\"index\":[],\"index_mut\":[],\"into\":[\"syn/extra-traits\"],\"into_iterator\":[],\"is_variant\":[\"dep:convert_case\"],\"mul\":[\"syn/extra-traits\"],\"mul_assign\":[\"syn/extra-traits\"],\"not\":[\"syn/extra-traits\"],\"sum\":[],\"testing-helpers\":[\"dep:rustc_version\"],\"try_from\":[],\"try_into\":[\"syn/extra-traits\"],\"try_unwrap\":[\"dep:convert_case\"],\"unwrap\":[\"dep:convert_case\"]}}", "derive_more-impl_2.1.1": "{\"dependencies\":[{\"name\":\"convert_case\",\"optional\":true,\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.14.0\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"kind\":\"build\",\"name\":\"rustc_version\",\"req\":\"^0.4\"},{\"name\":\"syn\",\"req\":\"^2.0.45\"},{\"name\":\"unicode-xid\",\"optional\":true,\"req\":\"^0.2.2\"}],\"features\":{\"add\":[\"syn/extra-traits\",\"syn/visit\"],\"add_assign\":[\"syn/extra-traits\",\"syn/visit\"],\"as_ref\":[\"syn/extra-traits\",\"syn/visit\"],\"constructor\":[],\"debug\":[\"syn/extra-traits\",\"dep:unicode-xid\"],\"default\":[],\"deref\":[],\"deref_mut\":[],\"display\":[\"syn/extra-traits\",\"dep:unicode-xid\",\"dep:convert_case\"],\"eq\":[\"syn/extra-traits\",\"syn/visit\"],\"error\":[\"syn/extra-traits\"],\"from\":[\"syn/extra-traits\"],\"from_str\":[\"syn/full\",\"syn/visit\",\"dep:convert_case\"],\"full\":[\"add\",\"add_assign\",\"as_ref\",\"constructor\",\"debug\",\"deref\",\"deref_mut\",\"display\",\"eq\",\"error\",\"from\",\"from_str\",\"index\",\"index_mut\",\"into\",\"into_iterator\",\"is_variant\",\"mul\",\"mul_assign\",\"not\",\"sum\",\"try_from\",\"try_into\",\"try_unwrap\",\"unwrap\"],\"index\":[],\"index_mut\":[],\"into\":[\"syn/extra-traits\",\"syn/visit-mut\"],\"into_iterator\":[],\"is_variant\":[\"dep:convert_case\"],\"mul\":[\"syn/extra-traits\",\"syn/visit\"],\"mul_assign\":[\"syn/extra-traits\",\"syn/visit\"],\"not\":[\"syn/extra-traits\"],\"sum\":[],\"testing-helpers\":[\"syn/full\"],\"try_from\":[],\"try_into\":[\"syn/extra-traits\",\"syn/full\",\"syn/visit-mut\"],\"try_unwrap\":[\"dep:convert_case\"],\"unwrap\":[\"dep:convert_case\"]}}", + "derive_more_0.99.20": "{\"dependencies\":[{\"name\":\"convert_case\",\"optional\":true,\"req\":\"^0.4\"},{\"kind\":\"build\",\"name\":\"peg\",\"optional\":true,\"req\":\"^0.5\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"kind\":\"build\",\"name\":\"rustc_version\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{\"add\":[],\"add_assign\":[],\"as_mut\":[],\"as_ref\":[],\"constructor\":[],\"default\":[\"add_assign\",\"add\",\"as_mut\",\"as_ref\",\"constructor\",\"deref\",\"deref_mut\",\"display\",\"error\",\"from\",\"from_str\",\"index\",\"index_mut\",\"into\",\"into_iterator\",\"iterator\",\"mul_assign\",\"mul\",\"not\",\"sum\",\"try_into\",\"is_variant\",\"unwrap\"],\"deref\":[],\"deref_mut\":[],\"display\":[\"syn/extra-traits\"],\"error\":[\"syn/extra-traits\"],\"from\":[\"syn/extra-traits\"],\"from_str\":[],\"generate-parsing-rs\":[\"peg\"],\"index\":[],\"index_mut\":[],\"into\":[\"syn/extra-traits\"],\"into_iterator\":[],\"is_variant\":[\"convert_case\"],\"iterator\":[],\"mul\":[\"syn/extra-traits\"],\"mul_assign\":[\"syn/extra-traits\"],\"nightly\":[],\"not\":[\"syn/extra-traits\"],\"sum\":[],\"testing-helpers\":[\"rustc_version\"],\"track-caller\":[],\"try_into\":[\"syn/extra-traits\"],\"unwrap\":[\"convert_case\",\"rustc_version\"]}}", "derive_more_1.0.0": "{\"dependencies\":[{\"name\":\"derive_more-impl\",\"req\":\"=1.0.0\"},{\"kind\":\"build\",\"name\":\"rustc_version\",\"optional\":true,\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.56\"}],\"features\":{\"add\":[\"derive_more-impl/add\"],\"add_assign\":[\"derive_more-impl/add_assign\"],\"as_ref\":[\"derive_more-impl/as_ref\"],\"constructor\":[\"derive_more-impl/constructor\"],\"debug\":[\"derive_more-impl/debug\"],\"default\":[\"std\"],\"deref\":[\"derive_more-impl/deref\"],\"deref_mut\":[\"derive_more-impl/deref_mut\"],\"display\":[\"derive_more-impl/display\"],\"error\":[\"derive_more-impl/error\"],\"from\":[\"derive_more-impl/from\"],\"from_str\":[\"derive_more-impl/from_str\"],\"full\":[\"add\",\"add_assign\",\"as_ref\",\"constructor\",\"debug\",\"deref\",\"deref_mut\",\"display\",\"error\",\"from\",\"from_str\",\"index\",\"index_mut\",\"into\",\"into_iterator\",\"is_variant\",\"mul\",\"mul_assign\",\"not\",\"sum\",\"try_from\",\"try_into\",\"try_unwrap\",\"unwrap\"],\"index\":[\"derive_more-impl/index\"],\"index_mut\":[\"derive_more-impl/index_mut\"],\"into\":[\"derive_more-impl/into\"],\"into_iterator\":[\"derive_more-impl/into_iterator\"],\"is_variant\":[\"derive_more-impl/is_variant\"],\"mul\":[\"derive_more-impl/mul\"],\"mul_assign\":[\"derive_more-impl/mul_assign\"],\"not\":[\"derive_more-impl/not\"],\"std\":[],\"sum\":[\"derive_more-impl/sum\"],\"testing-helpers\":[\"derive_more-impl/testing-helpers\",\"dep:rustc_version\"],\"try_from\":[\"derive_more-impl/try_from\"],\"try_into\":[\"derive_more-impl/try_into\"],\"try_unwrap\":[\"derive_more-impl/try_unwrap\"],\"unwrap\":[\"derive_more-impl/unwrap\"]}}", "derive_more_2.1.1": "{\"dependencies\":[{\"name\":\"derive_more-impl\",\"req\":\"=2.1.1\"},{\"kind\":\"build\",\"name\":\"rustc_version\",\"optional\":true,\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.56\"}],\"features\":{\"add\":[\"derive_more-impl/add\"],\"add_assign\":[\"derive_more-impl/add_assign\"],\"as_ref\":[\"derive_more-impl/as_ref\"],\"constructor\":[\"derive_more-impl/constructor\"],\"debug\":[\"derive_more-impl/debug\"],\"default\":[\"std\"],\"deref\":[\"derive_more-impl/deref\"],\"deref_mut\":[\"derive_more-impl/deref_mut\"],\"display\":[\"derive_more-impl/display\"],\"eq\":[\"derive_more-impl/eq\"],\"error\":[\"derive_more-impl/error\"],\"from\":[\"derive_more-impl/from\"],\"from_str\":[\"derive_more-impl/from_str\"],\"full\":[\"add\",\"add_assign\",\"as_ref\",\"constructor\",\"debug\",\"deref\",\"deref_mut\",\"display\",\"eq\",\"error\",\"from\",\"from_str\",\"index\",\"index_mut\",\"into\",\"into_iterator\",\"is_variant\",\"mul\",\"mul_assign\",\"not\",\"sum\",\"try_from\",\"try_into\",\"try_unwrap\",\"unwrap\"],\"index\":[\"derive_more-impl/index\"],\"index_mut\":[\"derive_more-impl/index_mut\"],\"into\":[\"derive_more-impl/into\"],\"into_iterator\":[\"derive_more-impl/into_iterator\"],\"is_variant\":[\"derive_more-impl/is_variant\"],\"mul\":[\"derive_more-impl/mul\"],\"mul_assign\":[\"derive_more-impl/mul_assign\"],\"not\":[\"derive_more-impl/not\"],\"std\":[],\"sum\":[\"derive_more-impl/sum\"],\"testing-helpers\":[\"derive_more-impl/testing-helpers\",\"dep:rustc_version\"],\"try_from\":[\"derive_more-impl/try_from\"],\"try_into\":[\"derive_more-impl/try_into\"],\"try_unwrap\":[\"derive_more-impl/try_unwrap\"],\"unwrap\":[\"derive_more-impl/unwrap\"]}}", "deunicode_1.6.2": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"default\":[\"alloc\"]}}", @@ -786,12 +791,15 @@ "displaydoc_0.2.5": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^0.6.1\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1\"},{\"name\":\"syn\",\"req\":\"^2.0\"},{\"kind\":\"dev\",\"name\":\"thiserror\",\"req\":\"^1.0.24\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "dotenvy_0.15.7": "{\"dependencies\":[{\"name\":\"clap\",\"optional\":true,\"req\":\"^3.2\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.16.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.3.0\"}],\"features\":{\"cli\":[\"clap\"]}}", "downcast-rs_1.2.1": "{\"dependencies\":[],\"features\":{\"default\":[\"std\"],\"std\":[]}}", + "dtoa-short_0.3.5": "{\"dependencies\":[{\"name\":\"dtoa\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"float-cmp\",\"req\":\"^0.4\"}],\"features\":{}}", + "dtoa_1.0.11": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\",\"target\":\"cfg(not(miri))\"},{\"name\":\"no-panic\",\"optional\":true,\"req\":\"^0.1\"}],\"features\":{}}", "dtor-proc-macro_0.0.6": "{\"dependencies\":[],\"features\":{\"default\":[]}}", "dtor_0.1.1": "{\"dependencies\":[{\"name\":\"dtor-proc-macro\",\"optional\":true,\"req\":\"=0.0.6\"},{\"kind\":\"dev\",\"name\":\"libc-print\",\"req\":\"^0.1.20\"}],\"features\":{\"__no_warn_on_missing_unsafe\":[],\"default\":[\"proc_macro\",\"__no_warn_on_missing_unsafe\"],\"proc_macro\":[\"dep:dtor-proc-macro\"],\"used_linker\":[]}}", "dunce_1.0.5": "{\"dependencies\":[],\"features\":{}}", "dupe_0.9.1": "{\"dependencies\":[{\"name\":\"dupe_derive\",\"req\":\"=0.9.1\"}],\"features\":{}}", "dupe_derive_0.9.1": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0.3\"},{\"features\":[\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{}}", "dyn-clone_1.0.20": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.66\"}],\"features\":{}}", + "ego-tree_0.6.3": "{\"dependencies\":[],\"features\":{}}", "either_1.15.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"alloc\",\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.95\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.0\"}],\"features\":{\"default\":[\"std\"],\"std\":[],\"use_std\":[\"std\"]}}", "ena_0.14.3": "{\"dependencies\":[{\"name\":\"dogged\",\"optional\":true,\"req\":\"^0.2.0\"},{\"name\":\"log\",\"req\":\"^0.4\"}],\"features\":{\"bench\":[],\"persistent\":[\"dogged\"]}}", "encode_unicode_1.0.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"ascii\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.0\",\"target\":\"cfg(unix)\"},{\"features\":[\"https-native\"],\"kind\":\"dev\",\"name\":\"minreq\",\"req\":\"^2.6\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", @@ -844,6 +852,7 @@ "form_urlencoded_1.2.2": "{\"dependencies\":[{\"default_features\":false,\"name\":\"percent-encoding\",\"req\":\"^2.3.0\"}],\"features\":{\"alloc\":[\"percent-encoding/alloc\"],\"default\":[\"std\"],\"std\":[\"alloc\",\"percent-encoding/std\"]}}", "fs_extra_1.3.0": "{\"dependencies\":[],\"features\":{}}", "fsevent-sys_4.1.0": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2.68\"}],\"features\":{}}", + "futf_0.1.5": "{\"dependencies\":[{\"name\":\"mac\",\"req\":\"^0.1.0\"},{\"name\":\"new_debug_unreachable\",\"req\":\"^1.0.2\"}],\"features\":{}}", "futures-channel_0.3.31": "{\"dependencies\":[{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-sink\",\"optional\":true,\"req\":\"^0.3.31\"}],\"features\":{\"alloc\":[\"futures-core/alloc\"],\"cfg-target-has-atomic\":[],\"default\":[\"std\"],\"sink\":[\"futures-sink\"],\"std\":[\"alloc\",\"futures-core/std\"],\"unstable\":[]}}", "futures-core_0.3.31": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"require-cas\"],\"name\":\"portable-atomic\",\"optional\":true,\"req\":\"^1.3\"}],\"features\":{\"alloc\":[],\"cfg-target-has-atomic\":[],\"default\":[\"std\"],\"std\":[\"alloc\"],\"unstable\":[]}}", "futures-executor_0.3.31": "{\"dependencies\":[{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-task\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-util\",\"req\":\"^0.3.31\"},{\"name\":\"num_cpus\",\"optional\":true,\"req\":\"^1.8.0\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"futures-core/std\",\"futures-task/std\",\"futures-util/std\"],\"thread-pool\":[\"std\",\"num_cpus\"]}}", @@ -873,6 +882,7 @@ "git+https://github.com/openai-oss-forks/tungstenite-rs?rev=9200079d3b54a1ff51072e24d81fd354f085156f#9200079d3b54a1ff51072e24d81fd354f085156f_tungstenite": "{\"dependencies\":[{\"name\":\"bytes\"},{\"default_features\":true,\"features\":[],\"name\":\"data-encoding\",\"optional\":true},{\"default_features\":false,\"features\":[\"zlib\"],\"name\":\"flate2\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"headers\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"http\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"httparse\",\"optional\":true},{\"name\":\"log\"},{\"default_features\":true,\"features\":[],\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\"},{\"name\":\"rand\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-native-certs\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-pki-types\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"sha1\",\"optional\":true},{\"name\":\"thiserror\"},{\"default_features\":true,\"features\":[],\"name\":\"url\",\"optional\":true},{\"name\":\"utf-8\"},{\"default_features\":true,\"features\":[],\"name\":\"webpki-roots\",\"optional\":true}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\"],\"default\":[\"handshake\"],\"deflate\":[\"headers\",\"flate2\"],\"handshake\":[\"data-encoding\",\"headers\",\"httparse\",\"sha1\"],\"headers\":[\"http\",\"dep:headers\"],\"native-tls\":[\"native-tls-crate\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\"],\"proxy\":[\"handshake\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"url\":[\"dep:url\"]},\"strip_prefix\":\"\"}", "glob_0.3.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"tempdir\",\"req\":\"^0.3\"}],\"features\":{}}", "globset_0.4.18": "{\"dependencies\":[{\"name\":\"aho-corasick\",\"req\":\"^1.1.1\"},{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.3.2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"bstr\",\"req\":\"^1.6.2\"},{\"kind\":\"dev\",\"name\":\"glob\",\"req\":\"^0.3.1\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.20\"},{\"default_features\":false,\"features\":[\"std\",\"perf\",\"syntax\",\"meta\",\"nfa\",\"hybrid\"],\"name\":\"regex-automata\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"regex-syntax\",\"req\":\"^0.8.0\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.188\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.107\"}],\"features\":{\"arbitrary\":[\"dep:arbitrary\"],\"default\":[\"log\"],\"serde1\":[\"serde\"],\"simd-accel\":[]}}", + "h2_0.3.27": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10\"},{\"name\":\"fnv\",\"req\":\"^1.0.5\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-sink\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"name\":\"http\",\"req\":\"^0.2\"},{\"features\":[\"std\"],\"name\":\"indexmap\",\"req\":\"^2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.4\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.0\"},{\"name\":\"slab\",\"req\":\"^0.4.2\"},{\"features\":[\"io-util\"],\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"rt-multi-thread\",\"macros\",\"sync\",\"net\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tokio-rustls\",\"req\":\"^0.24\"},{\"features\":[\"codec\",\"io\"],\"name\":\"tokio-util\",\"req\":\"^0.7.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"req\":\"^0.1.35\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.3.2\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^0.25\"}],\"features\":{\"stream\":[],\"unstable\":[]}}", "h2_0.4.13": "{\"dependencies\":[{\"name\":\"atomic-waker\",\"req\":\"^1.0.0\"},{\"name\":\"bytes\",\"req\":\"^1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10\"},{\"name\":\"fnv\",\"req\":\"^1.0.5\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-sink\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"name\":\"http\",\"req\":\"^1\"},{\"features\":[\"std\"],\"name\":\"indexmap\",\"req\":\"^2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.4\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.0\"},{\"name\":\"slab\",\"req\":\"^0.4.2\"},{\"features\":[\"io-util\"],\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"rt-multi-thread\",\"macros\",\"sync\",\"net\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tokio-rustls\",\"req\":\"^0.26\"},{\"features\":[\"codec\",\"io\"],\"name\":\"tokio-util\",\"req\":\"^0.7.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"req\":\"^0.1.35\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.3.2\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^1\"}],\"features\":{\"stream\":[],\"unstable\":[]}}", "half_2.7.1": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.4.1\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"bytemuck\",\"optional\":true,\"req\":\"^1.4.1\"},{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"name\":\"crunchy\",\"req\":\"^0.2.2\",\"target\":\"cfg(target_arch = \\\"spirv\\\")\"},{\"kind\":\"dev\",\"name\":\"crunchy\",\"req\":\"^0.2.2\"},{\"default_features\":false,\"features\":[\"libm\"],\"name\":\"num-traits\",\"optional\":true,\"req\":\"^0.2.16\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"quickcheck_macros\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"thread_rng\"],\"name\":\"rand\",\"optional\":true,\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9.0\"},{\"default_features\":false,\"name\":\"rand_distr\",\"optional\":true,\"req\":\"^0.5.0\"},{\"name\":\"rkyv\",\"optional\":true,\"req\":\"^0.8.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"derive\",\"simd\"],\"name\":\"zerocopy\",\"req\":\"^0.8.26\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"nightly\":[],\"rand_distr\":[\"dep:rand\",\"dep:rand_distr\"],\"std\":[\"alloc\"],\"use-intrinsics\":[],\"zerocopy\":[]}}", "hashbrown_0.12.3": "{\"dependencies\":[{\"default_features\":false,\"name\":\"ahash\",\"optional\":true,\"req\":\"^0.7.0\"},{\"name\":\"alloc\",\"optional\":true,\"package\":\"rustc-std-workspace-alloc\",\"req\":\"^1.0.0\"},{\"name\":\"bumpalo\",\"optional\":true,\"req\":\"^3.5.0\"},{\"name\":\"compiler_builtins\",\"optional\":true,\"req\":\"^0.1.2\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3.1\"},{\"kind\":\"dev\",\"name\":\"fnv\",\"req\":\"^1.0.7\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.4\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.3\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.25\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"}],\"features\":{\"ahash-compile-time-rng\":[\"ahash/compile-time-rng\"],\"default\":[\"ahash\",\"inline-more\"],\"inline-more\":[],\"nightly\":[],\"raw\":[],\"rustc-dep-of-std\":[\"nightly\",\"core\",\"compiler_builtins\",\"alloc\",\"rustc-internal-api\"],\"rustc-internal-api\":[]}}", @@ -892,7 +902,9 @@ "home_0.5.12": "{\"dependencies\":[{\"features\":[\"Win32_Foundation\",\"Win32_UI_Shell\",\"Win32_System_Com\"],\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "hostname_0.4.2": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(any(unix, target_os = \\\"redox\\\"))\"},{\"kind\":\"dev\",\"name\":\"similar-asserts\",\"req\":\"^1.6.1\"},{\"kind\":\"dev\",\"name\":\"version-sync\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"windows-bindgen\",\"req\":\"^0.65\"},{\"name\":\"windows-link\",\"req\":\"^0.2\",\"target\":\"cfg(target_os = \\\"windows\\\")\"}],\"features\":{\"default\":[],\"set\":[]}}", "hound_3.5.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"cpal\",\"req\":\"^0.2.12\"}],\"features\":{}}", + "html5ever_0.26.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\",\"target\":\"cfg(bench)\"},{\"name\":\"log\",\"req\":\"^0.4\"},{\"name\":\"mac\",\"req\":\"^0.1\"},{\"name\":\"markup5ever\",\"req\":\"^0.11\"},{\"kind\":\"build\",\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"kind\":\"build\",\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"extra-traits\",\"full\",\"fold\"],\"kind\":\"build\",\"name\":\"syn\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"typed-arena\",\"req\":\"^1.3.0\"}],\"features\":{}}", "http-body-util_0.1.3": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"name\":\"http\",\"req\":\"^1\"},{\"name\":\"http-body\",\"req\":\"^1\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"macros\",\"rt\",\"sync\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"}],\"features\":{\"channel\":[\"dep:tokio\"],\"default\":[],\"full\":[\"channel\"]}}", + "http-body_0.4.6": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1\"},{\"name\":\"http\",\"req\":\"^0.2\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"features\":[\"macros\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"}],\"features\":{}}", "http-body_1.0.1": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1\"},{\"name\":\"http\",\"req\":\"^1\"}],\"features\":{}}", "http-range-header_0.4.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"quickcheck_macros\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.8.3\"}],\"features\":{}}", "http_0.2.12": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"name\":\"fnv\",\"req\":\"^1.0.5\"},{\"kind\":\"dev\",\"name\":\"indexmap\",\"req\":\"<=1.8\"},{\"name\":\"itoa\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.7.0\"},{\"kind\":\"dev\",\"name\":\"seahash\",\"req\":\"^3.0.5\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{}}", @@ -901,8 +913,10 @@ "httpdate_1.0.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"}],\"features\":{}}", "hyper-rustls_0.27.7": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"http\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"http-body-util\",\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"hyper\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"client-legacy\",\"tokio\"],\"name\":\"hyper-util\",\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"server-auto\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.4\"},{\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"rustls\",\"req\":\"^0.23\"},{\"default_features\":false,\"features\":[\"tls12\"],\"kind\":\"dev\",\"name\":\"rustls\",\"req\":\"^0.23\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rustls-pemfile\",\"req\":\"^2\"},{\"name\":\"rustls-platform-verifier\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"tokio\",\"req\":\"^1.0\"},{\"features\":[\"io-std\",\"macros\",\"net\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"tokio-rustls\",\"req\":\"^0.26\"},{\"name\":\"tower-service\",\"req\":\"^0.3\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"aws-lc-rs\":[\"rustls/aws_lc_rs\"],\"default\":[\"native-tokio\",\"http1\",\"tls12\",\"logging\",\"aws-lc-rs\"],\"fips\":[\"aws-lc-rs\",\"rustls/fips\"],\"http1\":[\"hyper-util/http1\"],\"http2\":[\"hyper-util/http2\"],\"logging\":[\"log\",\"tokio-rustls/logging\",\"rustls/logging\"],\"native-tokio\":[\"rustls-native-certs\"],\"ring\":[\"rustls/ring\"],\"tls12\":[\"tokio-rustls/tls12\",\"rustls/tls12\"],\"webpki-tokio\":[\"webpki-roots\"]}}", "hyper-timeout_0.5.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"http-body-util\",\"req\":\"^0.1\"},{\"name\":\"hyper\",\"req\":\"^1.1\"},{\"features\":[\"http1\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1.1\"},{\"kind\":\"dev\",\"name\":\"hyper-tls\",\"req\":\"^0.6\"},{\"features\":[\"client-legacy\",\"http1\"],\"name\":\"hyper-util\",\"req\":\"^0.1.10\"},{\"features\":[\"client-legacy\",\"http1\",\"server\",\"server-graceful\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1.10\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"name\":\"tokio\",\"req\":\"^1.35\"},{\"features\":[\"io-std\",\"io-util\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.35\"},{\"name\":\"tower-service\",\"req\":\"^0.3\"}],\"features\":{}}", + "hyper-tls_0.5.0": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"tcp\",\"client\"],\"name\":\"hyper\",\"req\":\"^0.14.2\"},{\"default_features\":false,\"features\":[\"http1\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^0.14.2\"},{\"name\":\"native-tls\",\"req\":\"^0.2.1\"},{\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"io-std\",\"macros\",\"io-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0.0\"},{\"name\":\"tokio-native-tls\",\"req\":\"^0.3\"}],\"features\":{\"vendored\":[\"native-tls/vendored\"]}}", "hyper-tls_0.6.0": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1\"},{\"name\":\"http-body-util\",\"req\":\"^0.1.0\"},{\"name\":\"hyper\",\"req\":\"^1\"},{\"features\":[\"client-legacy\",\"tokio\"],\"name\":\"hyper-util\",\"req\":\"^0.1.0\"},{\"features\":[\"http1\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1.0\"},{\"name\":\"native-tls\",\"req\":\"^0.2.1\"},{\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"io-std\",\"macros\",\"io-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0.0\"},{\"name\":\"tokio-native-tls\",\"req\":\"^0.3\"},{\"name\":\"tower-service\",\"req\":\"^0.3\"}],\"features\":{\"alpn\":[\"native-tls/alpn\"],\"vendored\":[\"native-tls/vendored\"]}}", "hyper-util_0.1.19": "{\"dependencies\":[{\"name\":\"base64\",\"optional\":true,\"req\":\"^0.22\"},{\"name\":\"bytes\",\"req\":\"^1.7.1\"},{\"kind\":\"dev\",\"name\":\"bytes\",\"req\":\"^1\"},{\"name\":\"futures-channel\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"futures-core\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3.16\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.16\"},{\"name\":\"http\",\"req\":\"^1.0\"},{\"name\":\"http-body\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"http-body-util\",\"req\":\"^0.1.0\"},{\"name\":\"hyper\",\"req\":\"^1.8.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1.4.0\"},{\"name\":\"ipnet\",\"optional\":true,\"req\":\"^2.9\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"percent-encoding\",\"optional\":true,\"req\":\"^2.3\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.4\"},{\"kind\":\"dev\",\"name\":\"pnet_datalink\",\"req\":\"^0.35.0\",\"target\":\"cfg(any(target_os = \\\"linux\\\", target_os = \\\"macos\\\"))\"},{\"kind\":\"dev\",\"name\":\"pretty_env_logger\",\"req\":\"^0.5\"},{\"features\":[\"all\"],\"name\":\"socket2\",\"optional\":true,\"req\":\">=0.5.9, <0.7\"},{\"name\":\"system-configuration\",\"optional\":true,\"req\":\">=0.5, <0.7\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"macros\",\"test-util\",\"signal\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"name\":\"tower-layer\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"tower-service\",\"optional\":true,\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"tower-test\",\"req\":\"^0.4\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"windows-registry\",\"optional\":true,\"req\":\">=0.3, <0.7\",\"target\":\"cfg(windows)\"}],\"features\":{\"__internal_happy_eyeballs_tests\":[],\"client\":[\"hyper/client\",\"tokio/net\",\"dep:tracing\",\"dep:futures-channel\",\"dep:tower-service\"],\"client-legacy\":[\"client\",\"dep:socket2\",\"tokio/sync\",\"dep:libc\",\"dep:futures-util\"],\"client-pool\":[\"client\",\"dep:futures-util\",\"dep:tower-layer\"],\"client-proxy\":[\"client\",\"dep:base64\",\"dep:ipnet\",\"dep:percent-encoding\"],\"client-proxy-system\":[\"dep:system-configuration\",\"dep:windows-registry\"],\"default\":[],\"full\":[\"client\",\"client-legacy\",\"client-pool\",\"client-proxy\",\"client-proxy-system\",\"server\",\"server-auto\",\"server-graceful\",\"service\",\"http1\",\"http2\",\"tokio\",\"tracing\"],\"http1\":[\"hyper/http1\"],\"http2\":[\"hyper/http2\"],\"server\":[\"hyper/server\"],\"server-auto\":[\"server\",\"http1\",\"http2\"],\"server-graceful\":[\"server\",\"tokio/sync\"],\"service\":[\"dep:tower-service\"],\"tokio\":[\"dep:tokio\",\"tokio/rt\",\"tokio/time\"],\"tracing\":[\"dep:tracing\"]}}", + "hyper_0.14.32": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1\"},{\"name\":\"futures-channel\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"name\":\"h2\",\"optional\":true,\"req\":\"^0.3.24\"},{\"name\":\"http\",\"req\":\"^0.2\"},{\"name\":\"http-body\",\"req\":\"^0.4\"},{\"name\":\"httparse\",\"req\":\"^1.8\"},{\"name\":\"httpdate\",\"req\":\"^1.0\"},{\"name\":\"itoa\",\"req\":\"^1\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"matches\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"^1.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.4\"},{\"kind\":\"dev\",\"name\":\"pnet_datalink\",\"req\":\"^0.27.2\",\"target\":\"cfg(any(target_os = \\\"linux\\\", target_os = \\\"macos\\\"))\"},{\"kind\":\"dev\",\"name\":\"pretty_env_logger\",\"req\":\"^0.4\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"all\"],\"name\":\"socket2\",\"optional\":true,\"req\":\">=0.4.7, <0.6.0\"},{\"kind\":\"dev\",\"name\":\"spmc\",\"req\":\"^0.3\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1.27\"},{\"features\":[\"fs\",\"macros\",\"io-std\",\"io-util\",\"rt\",\"rt-multi-thread\",\"sync\",\"time\",\"test-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.27\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"features\":[\"codec\"],\"kind\":\"dev\",\"name\":\"tokio-util\",\"req\":\"^0.7\"},{\"default_features\":false,\"features\":[\"make\",\"util\"],\"kind\":\"dev\",\"name\":\"tower\",\"req\":\"^0.4\"},{\"name\":\"tower-service\",\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"url\",\"req\":\"^2.2\"},{\"name\":\"want\",\"req\":\"^0.3\"}],\"features\":{\"__internal_happy_eyeballs_tests\":[],\"backports\":[],\"client\":[],\"default\":[],\"deprecated\":[],\"ffi\":[\"libc\"],\"full\":[\"client\",\"http1\",\"http2\",\"server\",\"stream\",\"runtime\"],\"http1\":[],\"http2\":[\"h2\"],\"nightly\":[],\"runtime\":[\"tcp\",\"tokio/rt\",\"tokio/time\"],\"server\":[],\"stream\":[],\"tcp\":[\"socket2\",\"tokio/net\",\"tokio/rt\",\"tokio/time\"]}}", "hyper_1.8.1": "{\"dependencies\":[{\"name\":\"atomic-waker\",\"optional\":true,\"req\":\"^1.1.2\"},{\"name\":\"bytes\",\"req\":\"^1.2\"},{\"kind\":\"dev\",\"name\":\"form_urlencoded\",\"req\":\"^1\"},{\"name\":\"futures-channel\",\"optional\":true,\"req\":\"^0.3\"},{\"features\":[\"sink\"],\"kind\":\"dev\",\"name\":\"futures-channel\",\"req\":\"^0.3\"},{\"name\":\"futures-core\",\"optional\":true,\"req\":\"^0.3.31\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"alloc\",\"sink\"],\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"name\":\"h2\",\"optional\":true,\"req\":\"^0.4.2\"},{\"name\":\"http\",\"req\":\"^1\"},{\"name\":\"http-body\",\"req\":\"^1\"},{\"name\":\"http-body-util\",\"optional\":true,\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"http-body-util\",\"req\":\"^0.1\"},{\"name\":\"httparse\",\"optional\":true,\"req\":\"^1.9\"},{\"name\":\"httpdate\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"itoa\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"pin-project-lite\",\"optional\":true,\"req\":\"^0.2.4\"},{\"kind\":\"dev\",\"name\":\"pin-project-lite\",\"req\":\"^0.2.4\"},{\"name\":\"pin-utils\",\"optional\":true,\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"pretty_env_logger\",\"req\":\"^0.5\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"const_generics\",\"const_new\"],\"name\":\"smallvec\",\"optional\":true,\"req\":\"^1.12\"},{\"kind\":\"dev\",\"name\":\"spmc\",\"req\":\"^0.3\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"fs\",\"macros\",\"net\",\"io-std\",\"io-util\",\"rt\",\"rt-multi-thread\",\"sync\",\"time\",\"test-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"tokio-util\",\"req\":\"^0.7.10\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"want\",\"optional\":true,\"req\":\"^0.3\"}],\"features\":{\"capi\":[],\"client\":[\"dep:want\",\"dep:pin-project-lite\",\"dep:smallvec\"],\"default\":[],\"ffi\":[\"dep:http-body-util\",\"dep:futures-util\"],\"full\":[\"client\",\"http1\",\"http2\",\"server\"],\"http1\":[\"dep:atomic-waker\",\"dep:futures-channel\",\"dep:futures-core\",\"dep:httparse\",\"dep:itoa\",\"dep:pin-utils\"],\"http2\":[\"dep:futures-channel\",\"dep:futures-core\",\"dep:h2\"],\"nightly\":[],\"server\":[\"dep:httpdate\",\"dep:pin-project-lite\",\"dep:smallvec\"],\"tracing\":[\"dep:tracing\"]}}", "i18n-config_0.4.8": "{\"dependencies\":[{\"name\":\"basic-toml\",\"req\":\"^0.1\"},{\"name\":\"log\",\"req\":\"^0.4\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_derive\",\"req\":\"^1.0\"},{\"name\":\"thiserror\",\"req\":\"^1.0\"},{\"features\":[\"serde\"],\"name\":\"unic-langid\",\"req\":\"^0.9\"}],\"features\":{}}", "i18n-embed-fl_0.9.4": "{\"dependencies\":[{\"name\":\"dashmap\",\"optional\":true,\"req\":\"^6.0\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"name\":\"find-crate\",\"req\":\"^0.6\"},{\"name\":\"fluent\",\"req\":\"^0.16\"},{\"name\":\"fluent-syntax\",\"req\":\"^0.11\"},{\"name\":\"i18n-config\",\"req\":\"^0.4.7\"},{\"features\":[\"fluent-system\",\"filesystem-assets\"],\"name\":\"i18n-embed\",\"req\":\"^0.15.4\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.4\"},{\"name\":\"proc-macro-error2\",\"req\":\"^2.0.1\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rust-embed\",\"req\":\"^8.0\"},{\"name\":\"strsim\",\"req\":\"^0.11\"},{\"features\":[\"derive\",\"proc-macro\",\"parsing\",\"printing\",\"extra-traits\",\"full\"],\"name\":\"syn\",\"req\":\"^2.0\"},{\"name\":\"unic-langid\",\"req\":\"^0.9\"}],\"features\":{\"dashmap\":[\"dep:dashmap\"]}}", @@ -991,8 +1005,10 @@ "lsp-types_0.94.1": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^1.0.1\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0.34\"},{\"name\":\"serde_json\",\"req\":\"^1.0.50\"},{\"name\":\"serde_repr\",\"req\":\"^0.1\"},{\"features\":[\"serde\"],\"name\":\"url\",\"req\":\"^2.0.0\"}],\"features\":{\"default\":[],\"proposed\":[]}}", "lzma-rs_0.3.0": "{\"dependencies\":[{\"name\":\"byteorder\",\"req\":\"^1.4.3\"},{\"name\":\"crc\",\"req\":\"^3.0.0\"},{\"name\":\"env_logger\",\"optional\":true,\"req\":\"^0.9.0\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.17\"},{\"kind\":\"dev\",\"name\":\"rust-lzma\",\"req\":\"^0.5\"}],\"features\":{\"enable_logging\":[\"env_logger\",\"log\"],\"raw_decoder\":[],\"stream\":[]}}", "lzma-sys_0.1.20": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.34\"},{\"name\":\"libc\",\"req\":\"^0.2.51\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.14\"}],\"features\":{\"static\":[]}}", + "mac_0.1.1": "{\"dependencies\":[],\"features\":{}}", "mach2_0.4.3": "{\"dependencies\":[{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(any(target_os = \\\"macos\\\", target_os = \\\"ios\\\"))\"}],\"features\":{\"default\":[],\"unstable\":[]}}", "maplit_1.0.2": "{\"dependencies\":[],\"features\":{}}", + "markup5ever_0.11.0": "{\"dependencies\":[{\"name\":\"log\",\"req\":\"^0.4\"},{\"name\":\"phf\",\"req\":\"^0.10\"},{\"kind\":\"build\",\"name\":\"phf_codegen\",\"req\":\"^0.10\"},{\"name\":\"string_cache\",\"req\":\"^0.8\"},{\"kind\":\"build\",\"name\":\"string_cache_codegen\",\"req\":\"^0.5.1\"},{\"name\":\"tendril\",\"req\":\"^0.4\"}],\"features\":{}}", "matchers_0.2.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"syntax\",\"dfa-build\",\"dfa-search\"],\"name\":\"regex-automata\",\"req\":\"^0.4\"}],\"features\":{\"unicode\":[\"regex-automata/unicode\"]}}", "matchit_0.8.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"actix-router\",\"req\":\"^0.2.7\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.4\"},{\"kind\":\"dev\",\"name\":\"gonzales\",\"req\":\"^0.0.3-beta\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^0.14\"},{\"kind\":\"dev\",\"name\":\"path-tree\",\"req\":\"^0.2.2\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.5.4\"},{\"kind\":\"dev\",\"name\":\"route-recognizer\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"routefinder\",\"req\":\"^0.5.2\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"make\",\"util\"],\"kind\":\"dev\",\"name\":\"tower\",\"req\":\"^0.4\"}],\"features\":{\"__test_helpers\":[],\"default\":[]}}", "matchit_0.9.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"actix-router\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"gonzales\",\"req\":\"^0.0.3-beta\"},{\"kind\":\"dev\",\"name\":\"http-body-util\",\"req\":\"^0.1\"},{\"features\":[\"http1\",\"server\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1\"},{\"features\":[\"tokio\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"path-tree\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"route-recognizer\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"routefinder\",\"req\":\"^0.5\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"make\",\"util\"],\"kind\":\"dev\",\"name\":\"tower\",\"req\":\"^0.5.2\"},{\"kind\":\"dev\",\"name\":\"wayfind\",\"req\":\"^0.8\"}],\"features\":{\"__test_helpers\":[],\"default\":[]}}", @@ -1084,17 +1100,26 @@ "parking_2.2.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"easy-parallel\",\"req\":\"^3.0.0\"},{\"name\":\"loom\",\"optional\":true,\"req\":\"^0.7\",\"target\":\"cfg(loom)\"}],\"features\":{}}", "parking_lot_0.12.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.3\"},{\"name\":\"lock_api\",\"req\":\"^0.4.14\"},{\"name\":\"parking_lot_core\",\"req\":\"^0.9.12\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.3\"}],\"features\":{\"arc_lock\":[\"lock_api/arc_lock\"],\"deadlock_detection\":[\"parking_lot_core/deadlock_detection\"],\"default\":[],\"hardware-lock-elision\":[],\"nightly\":[\"parking_lot_core/nightly\",\"lock_api/nightly\"],\"owning_ref\":[\"lock_api/owning_ref\"],\"send_guard\":[],\"serde\":[\"lock_api/serde\"]}}", "parking_lot_core_0.9.12": "{\"dependencies\":[{\"name\":\"backtrace\",\"optional\":true,\"req\":\"^0.3.60\"},{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"name\":\"libc\",\"req\":\"^0.2.95\",\"target\":\"cfg(unix)\"},{\"name\":\"petgraph\",\"optional\":true,\"req\":\"^0.6.0\"},{\"name\":\"redox_syscall\",\"req\":\"^0.5\",\"target\":\"cfg(target_os = \\\"redox\\\")\"},{\"name\":\"smallvec\",\"req\":\"^1.6.1\"},{\"name\":\"windows-link\",\"req\":\"^0.2.0\",\"target\":\"cfg(windows)\"}],\"features\":{\"deadlock_detection\":[\"petgraph\",\"backtrace\"],\"nightly\":[]}}", + "password-hash_0.4.2": "{\"dependencies\":[{\"name\":\"base64ct\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.6\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2\"}],\"features\":{\"alloc\":[\"base64ct/alloc\"],\"default\":[\"rand_core\"],\"std\":[\"alloc\",\"base64ct/std\",\"rand_core/std\"]}}", "paste_1.0.15": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"paste-test-suite\",\"req\":\"^0\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.49\"}],\"features\":{}}", "pastey_0.2.1": "{\"dependencies\":[],\"features\":{}}", "path-absolutize_3.1.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"name\":\"path-dedot\",\"req\":\"^3.1.1\"},{\"kind\":\"dev\",\"name\":\"slash-formatter\",\"req\":\"^3\",\"target\":\"cfg(windows)\"}],\"features\":{\"lazy_static_cache\":[\"path-dedot/lazy_static_cache\"],\"once_cell_cache\":[\"path-dedot/once_cell_cache\"],\"unsafe_cache\":[\"path-dedot/unsafe_cache\"],\"use_unix_paths_on_wasm\":[\"path-dedot/use_unix_paths_on_wasm\"]}}", "path-dedot_3.1.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"name\":\"lazy_static\",\"optional\":true,\"req\":\"^1.4\"},{\"name\":\"once_cell\",\"req\":\"^1.4\"}],\"features\":{\"lazy_static_cache\":[\"lazy_static\"],\"once_cell_cache\":[],\"unsafe_cache\":[],\"use_unix_paths_on_wasm\":[]}}", "pathdiff_0.2.3": "{\"dependencies\":[{\"name\":\"camino\",\"optional\":true,\"req\":\"^1.0.5\"},{\"kind\":\"dev\",\"name\":\"cfg-if\",\"req\":\"^1.0.0\"}],\"features\":{}}", + "pbkdf2_0.11.0": "{\"dependencies\":[{\"features\":[\"mac\"],\"name\":\"digest\",\"req\":\"^0.10.3\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"hmac\",\"optional\":true,\"req\":\"^0.12\"},{\"kind\":\"dev\",\"name\":\"hmac\",\"req\":\"^0.12\"},{\"default_features\":false,\"features\":[\"rand_core\"],\"name\":\"password-hash\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.2\"},{\"default_features\":false,\"name\":\"sha1\",\"optional\":true,\"package\":\"sha-1\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"sha1\",\"package\":\"sha-1\",\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"streebog\",\"req\":\"^0.10\"}],\"features\":{\"default\":[\"simple\"],\"parallel\":[\"rayon\",\"std\"],\"simple\":[\"hmac\",\"password-hash\",\"sha2\"],\"std\":[\"password-hash/std\"]}}", "pbkdf2_0.12.2": "{\"dependencies\":[{\"features\":[\"mac\"],\"name\":\"digest\",\"req\":\"^0.10.7\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"name\":\"hmac\",\"optional\":true,\"req\":\"^0.12\"},{\"kind\":\"dev\",\"name\":\"hmac\",\"req\":\"^0.12\"},{\"default_features\":false,\"features\":[\"rand_core\"],\"name\":\"password-hash\",\"optional\":true,\"req\":\"^0.5\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.7\"},{\"default_features\":false,\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"sha1\",\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"streebog\",\"req\":\"^0.10\"}],\"features\":{\"default\":[\"hmac\"],\"parallel\":[\"rayon\",\"std\"],\"simple\":[\"hmac\",\"password-hash\",\"sha2\"],\"std\":[\"password-hash/std\"]}}", "pem-rfc7468_0.7.0": "{\"dependencies\":[{\"name\":\"base64ct\",\"req\":\"^1.4\"}],\"features\":{\"alloc\":[\"base64ct/alloc\"],\"std\":[\"alloc\",\"base64ct/std\"]}}", "pem_3.0.6": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"base64\",\"req\":\"^0.22.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.0\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"}],\"features\":{\"default\":[\"std\"],\"serde\":[\"dep:serde_core\"],\"std\":[\"base64/std\",\"serde_core?/std\"]}}", "percent-encoding_2.3.2": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", "petgraph_0.6.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"ahash\",\"req\":\"^0.7.2\"},{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.3\"},{\"kind\":\"dev\",\"name\":\"defmac\",\"req\":\"^0.2.1\"},{\"default_features\":false,\"name\":\"fixedbitset\",\"req\":\"^0.4.0\"},{\"kind\":\"dev\",\"name\":\"fxhash\",\"req\":\"^0.2.1\"},{\"name\":\"indexmap\",\"req\":\"^2.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.12.1\"},{\"kind\":\"dev\",\"name\":\"odds\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.5.5\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.5.3\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde_derive\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"all\":[\"unstable\",\"quickcheck\",\"matrix_graph\",\"stable_graph\",\"graphmap\",\"rayon\"],\"default\":[\"graphmap\",\"stable_graph\",\"matrix_graph\"],\"generate\":[],\"graphmap\":[],\"matrix_graph\":[],\"rayon\":[\"dep:rayon\",\"indexmap/rayon\"],\"serde-1\":[\"serde\",\"serde_derive\"],\"stable_graph\":[],\"unstable\":[\"generate\"]}}", "petgraph_0.8.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"ahash\",\"req\":\"^0.7.2\"},{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.3\"},{\"kind\":\"dev\",\"name\":\"defmac\",\"req\":\"^0.2.1\"},{\"name\":\"dot-parser\",\"optional\":true,\"req\":\"^0.5.1\"},{\"name\":\"dot-parser-macros\",\"optional\":true,\"req\":\"^0.5.1\"},{\"default_features\":false,\"name\":\"fixedbitset\",\"req\":\"^0.5.7\"},{\"kind\":\"dev\",\"name\":\"fxhash\",\"req\":\"^0.2.1\"},{\"default_features\":false,\"features\":[\"default-hasher\",\"inline-more\"],\"name\":\"hashbrown\",\"req\":\"^0.15.0\"},{\"default_features\":false,\"name\":\"indexmap\",\"req\":\"^2.5.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.12.1\"},{\"kind\":\"dev\",\"name\":\"odds\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.5.5\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.5.3\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde_derive\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"all\":[\"unstable\",\"quickcheck\",\"matrix_graph\",\"stable_graph\",\"graphmap\",\"rayon\",\"dot_parser\"],\"default\":[\"std\",\"graphmap\",\"stable_graph\",\"matrix_graph\"],\"dot_parser\":[\"std\",\"dep:dot-parser\",\"dep:dot-parser-macros\"],\"generate\":[],\"graphmap\":[],\"matrix_graph\":[],\"quickcheck\":[\"std\",\"dep:quickcheck\",\"graphmap\",\"stable_graph\"],\"rayon\":[\"std\",\"dep:rayon\",\"indexmap/rayon\",\"hashbrown/rayon\"],\"serde-1\":[\"serde\",\"serde_derive\"],\"stable_graph\":[\"serde?/alloc\"],\"std\":[\"indexmap/std\"],\"unstable\":[\"generate\"]}}", + "phf_0.10.1": "{\"dependencies\":[{\"name\":\"phf_macros\",\"optional\":true,\"req\":\"^0.10.0\"},{\"default_features\":false,\"name\":\"phf_shared\",\"req\":\"^0.10.0\"},{\"name\":\"proc-macro-hack\",\"optional\":true,\"req\":\"^0.5.4\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"macros\":[\"phf_macros\",\"proc-macro-hack\"],\"std\":[\"phf_shared/std\"],\"uncased\":[\"phf_shared/uncased\"],\"unicase\":[\"phf_shared/unicase\"]}}", + "phf_0.11.3": "{\"dependencies\":[{\"name\":\"phf_macros\",\"optional\":true,\"req\":\"^0.11.3\"},{\"default_features\":false,\"name\":\"phf_shared\",\"req\":\"^0.11.3\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"macros\":[\"phf_macros\"],\"std\":[\"phf_shared/std\"],\"uncased\":[\"phf_shared/uncased\"],\"unicase\":[\"phf_macros?/unicase\",\"phf_shared/unicase\"]}}", + "phf_codegen_0.10.0": "{\"dependencies\":[{\"name\":\"phf_generator\",\"req\":\"^0.10.0\"},{\"name\":\"phf_shared\",\"req\":\"^0.10.0\"}],\"features\":{}}", + "phf_generator_0.10.0": "{\"dependencies\":[{\"name\":\"criterion\",\"optional\":true,\"req\":\"=0.3.4\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"=0.3.4\"},{\"default_features\":false,\"name\":\"phf_shared\",\"req\":\"^0.10.0\"},{\"features\":[\"small_rng\"],\"name\":\"rand\",\"req\":\"^0.8\"}],\"features\":{}}", + "phf_generator_0.11.3": "{\"dependencies\":[{\"name\":\"criterion\",\"optional\":true,\"req\":\"^0.3.6\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.6\"},{\"default_features\":false,\"name\":\"phf_shared\",\"req\":\"^0.11.2\"},{\"default_features\":false,\"features\":[\"small_rng\"],\"name\":\"rand\",\"req\":\"^0.8\"}],\"features\":{}}", + "phf_macros_0.11.3": "{\"dependencies\":[{\"name\":\"phf_generator\",\"req\":\"^0.11.1\"},{\"default_features\":false,\"name\":\"phf_shared\",\"req\":\"^0.11.2\"},{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2\"},{\"name\":\"unicase_\",\"optional\":true,\"package\":\"unicase\",\"req\":\"^2.4.0\"}],\"features\":{\"unicase\":[\"unicase_\",\"phf_shared/unicase\"]}}", + "phf_shared_0.10.0": "{\"dependencies\":[{\"name\":\"siphasher\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"uncased\",\"optional\":true,\"req\":\"^0.9.6\"},{\"name\":\"unicase\",\"optional\":true,\"req\":\"^2.4.0\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "phf_shared_0.11.3": "{\"dependencies\":[{\"name\":\"siphasher\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"uncased\",\"optional\":true,\"req\":\"^0.9.9\"},{\"name\":\"unicase\",\"optional\":true,\"req\":\"^2.4.0\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "pin-project-internal_1.1.10": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.60\"},{\"name\":\"quote\",\"req\":\"^1.0.25\"},{\"default_features\":false,\"features\":[\"parsing\",\"printing\",\"clone-impls\",\"proc-macro\",\"full\",\"visit-mut\"],\"name\":\"syn\",\"req\":\"^2.0.1\"}],\"features\":{}}", "pin-project-lite_0.2.16": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1\"}],\"features\":{}}", @@ -1113,6 +1138,7 @@ "portable-pty_0.9.0": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"name\":\"bitflags\",\"req\":\"^1.3\",\"target\":\"cfg(windows)\"},{\"name\":\"downcast-rs\",\"req\":\"^1.0\"},{\"name\":\"filedescriptor\",\"req\":\"^0.8.3\"},{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"name\":\"lazy_static\",\"req\":\"^1.4\",\"target\":\"cfg(windows)\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"name\":\"log\",\"req\":\"^0.4\"},{\"features\":[\"term\",\"fs\"],\"name\":\"nix\",\"req\":\"^0.28\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde_derive\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serial2\",\"req\":\"^0.2\"},{\"name\":\"shared_library\",\"req\":\"^0.1\",\"target\":\"cfg(windows)\"},{\"name\":\"shell-words\",\"req\":\"^1.1\"},{\"kind\":\"dev\",\"name\":\"smol\",\"req\":\"^2.0\"},{\"features\":[\"winuser\",\"consoleapi\",\"handleapi\",\"fileapi\",\"namedpipeapi\",\"synchapi\"],\"name\":\"winapi\",\"req\":\"^0.3\",\"target\":\"cfg(windows)\"},{\"name\":\"winreg\",\"req\":\"^0.10\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[],\"serde_support\":[\"serde\",\"serde_derive\"]}}", "potential_utf_0.1.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.1\"},{\"default_features\":false,\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"default_features\":false,\"name\":\"writeable\",\"optional\":true,\"req\":\"^0.6.0\"},{\"default_features\":false,\"name\":\"zerovec\",\"optional\":true,\"req\":\"^0.11.3\"}],\"features\":{\"alloc\":[\"serde_core?/alloc\",\"writeable/alloc\",\"zerovec?/alloc\"],\"databake\":[\"dep:databake\"],\"default\":[\"alloc\"],\"serde\":[\"dep:serde_core\"],\"writeable\":[\"dep:writeable\"],\"zerovec\":[\"dep:zerovec\"]}}", "powerfmt_0.2.0": "{\"dependencies\":[{\"name\":\"powerfmt-macros\",\"optional\":true,\"req\":\"=0.1.0\"}],\"features\":{\"alloc\":[],\"default\":[\"std\",\"macros\"],\"macros\":[\"dep:powerfmt-macros\"],\"std\":[\"alloc\"]}}", + "ppt-rs_0.2.6": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"clock\",\"std\"],\"name\":\"chrono\",\"req\":\"^0.4\"},{\"features\":[\"derive\"],\"name\":\"clap\",\"req\":\"^4.5\"},{\"name\":\"pulldown-cmark\",\"req\":\"^0.10\"},{\"name\":\"regex\",\"req\":\"^1.10\"},{\"features\":[\"blocking\"],\"name\":\"reqwest\",\"optional\":true,\"req\":\"^0.11\"},{\"name\":\"scraper\",\"optional\":true,\"req\":\"^0.18\"},{\"name\":\"syntect\",\"req\":\"^5.2\"},{\"name\":\"thiserror\",\"req\":\"^1.0\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2.5\"},{\"features\":[\"v4\"],\"name\":\"uuid\",\"req\":\"^1.0\"},{\"name\":\"xml-rs\",\"req\":\"^0.8\"},{\"name\":\"zip\",\"req\":\"^0.6\"}],\"features\":{\"default\":[\"web2ppt\"],\"web2ppt\":[\"reqwest\",\"scraper\",\"url\"]}}", "ppv-lite86_0.2.21": "{\"dependencies\":[{\"features\":[\"simd\"],\"name\":\"zerocopy\",\"req\":\"^0.8.23\"}],\"features\":{\"default\":[\"std\"],\"no_simd\":[],\"simd\":[],\"std\":[]}}", "precomputed-hash_0.1.1": "{\"dependencies\":[],\"features\":{}}", "predicates-core_1.0.9": "{\"dependencies\":[],\"features\":{}}", @@ -1179,6 +1205,7 @@ "regex-syntax_0.6.29": "{\"dependencies\":[],\"features\":{\"default\":[\"unicode\"],\"unicode\":[\"unicode-age\",\"unicode-bool\",\"unicode-case\",\"unicode-gencat\",\"unicode-perl\",\"unicode-script\",\"unicode-segment\"],\"unicode-age\":[],\"unicode-bool\":[],\"unicode-case\":[],\"unicode-gencat\":[],\"unicode-perl\":[],\"unicode-script\":[],\"unicode-segment\":[]}}", "regex-syntax_0.8.8": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.3.0\"}],\"features\":{\"arbitrary\":[\"dep:arbitrary\"],\"default\":[\"std\",\"unicode\"],\"std\":[],\"unicode\":[\"unicode-age\",\"unicode-bool\",\"unicode-case\",\"unicode-gencat\",\"unicode-perl\",\"unicode-script\",\"unicode-segment\"],\"unicode-age\":[],\"unicode-bool\":[],\"unicode-case\":[],\"unicode-gencat\":[],\"unicode-perl\":[],\"unicode-script\":[],\"unicode-segment\":[]}}", "regex_1.12.3": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aho-corasick\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.69\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"atty\",\"humantime\",\"termcolor\"],\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.9.3\"},{\"default_features\":false,\"name\":\"memchr\",\"optional\":true,\"req\":\"^2.6.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"default_features\":false,\"features\":[\"alloc\",\"syntax\",\"meta\",\"nfa-pikevm\"],\"name\":\"regex-automata\",\"req\":\"^0.4.12\"},{\"default_features\":false,\"name\":\"regex-syntax\",\"req\":\"^0.8.5\"},{\"kind\":\"dev\",\"name\":\"regex-test\",\"req\":\"^0.1.0\"}],\"features\":{\"default\":[\"std\",\"perf\",\"unicode\",\"regex-syntax/default\"],\"logging\":[\"aho-corasick?/logging\",\"memchr?/logging\",\"regex-automata/logging\"],\"pattern\":[],\"perf\":[\"perf-cache\",\"perf-dfa\",\"perf-onepass\",\"perf-backtrack\",\"perf-inline\",\"perf-literal\"],\"perf-backtrack\":[\"regex-automata/nfa-backtrack\"],\"perf-cache\":[],\"perf-dfa\":[\"regex-automata/hybrid\"],\"perf-dfa-full\":[\"regex-automata/dfa-build\",\"regex-automata/dfa-search\"],\"perf-inline\":[\"regex-automata/perf-inline\"],\"perf-literal\":[\"dep:aho-corasick\",\"dep:memchr\",\"regex-automata/perf-literal\"],\"perf-onepass\":[\"regex-automata/dfa-onepass\"],\"std\":[\"aho-corasick?/std\",\"memchr?/std\",\"regex-automata/std\",\"regex-syntax/std\"],\"unicode\":[\"unicode-age\",\"unicode-bool\",\"unicode-case\",\"unicode-gencat\",\"unicode-perl\",\"unicode-script\",\"unicode-segment\",\"regex-automata/unicode\",\"regex-syntax/unicode\"],\"unicode-age\":[\"regex-automata/unicode-age\",\"regex-syntax/unicode-age\"],\"unicode-bool\":[\"regex-automata/unicode-bool\",\"regex-syntax/unicode-bool\"],\"unicode-case\":[\"regex-automata/unicode-case\",\"regex-syntax/unicode-case\"],\"unicode-gencat\":[\"regex-automata/unicode-gencat\",\"regex-syntax/unicode-gencat\"],\"unicode-perl\":[\"regex-automata/unicode-perl\",\"regex-automata/unicode-word-boundary\",\"regex-syntax/unicode-perl\"],\"unicode-script\":[\"regex-automata/unicode-script\",\"regex-syntax/unicode-script\"],\"unicode-segment\":[\"regex-automata/unicode-segment\",\"regex-syntax/unicode-segment\"],\"unstable\":[\"pattern\"],\"use_std\":[\"std\"]}}", + "reqwest_0.11.27": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"tokio\"],\"name\":\"async-compression\",\"optional\":true,\"req\":\"^0.4.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"base64\",\"req\":\"^0.21\"},{\"kind\":\"dev\",\"name\":\"brotli_crate\",\"package\":\"brotli\",\"req\":\"^3.3.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"bytes\",\"req\":\"^1.0\"},{\"name\":\"cookie_crate\",\"optional\":true,\"package\":\"cookie\",\"req\":\"^0.17.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"cookie_store\",\"optional\":true,\"req\":\"^0.20.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"encoding_rs\",\"req\":\"^0.8\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"futures-channel\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.0\"},{\"default_features\":false,\"name\":\"futures-util\",\"req\":\"^0.3.0\"},{\"default_features\":false,\"features\":[\"std\",\"alloc\"],\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"h2\",\"req\":\"^0.3.14\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"h3\",\"optional\":true,\"req\":\"^0.0.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"h3-quinn\",\"optional\":true,\"req\":\"^0.0.4\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"tokio-runtime\"],\"name\":\"hickory-resolver\",\"optional\":true,\"req\":\"^0.24\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"http\",\"req\":\"^0.2\"},{\"name\":\"http-body\",\"req\":\"^0.4.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"tcp\",\"http1\",\"http2\",\"client\",\"runtime\"],\"name\":\"hyper\",\"req\":\"^0.14.21\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"tcp\",\"stream\",\"http1\",\"http2\",\"client\",\"server\",\"runtime\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^0.14\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"name\":\"hyper-rustls\",\"optional\":true,\"req\":\"^0.24.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"hyper-tls\",\"optional\":true,\"req\":\"^0.5\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"ipnet\",\"req\":\"^2.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"js-sys\",\"req\":\"^0.3.45\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"kind\":\"dev\",\"name\":\"libflate\",\"req\":\"^1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"log\",\"req\":\"^0.4\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"mime\",\"req\":\"^0.3.16\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"name\":\"mime_guess\",\"optional\":true,\"req\":\"^2.0\"},{\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\",\"req\":\"^0.2.10\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"once_cell\",\"req\":\"^1\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"percent-encoding\",\"req\":\"^2.1\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"tls-rustls\",\"ring\",\"runtime-tokio\"],\"name\":\"quinn\",\"optional\":true,\"req\":\"^0.10\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"dangerous_configuration\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.21.6\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.6\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"rustls-pemfile\",\"optional\":true,\"req\":\"^1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"serde\",\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"serde_json\",\"req\":\"^1.0\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde_urlencoded\",\"req\":\"^0.7.1\"},{\"name\":\"sync_wrapper\",\"req\":\"^0.1.2\"},{\"name\":\"system-configuration\",\"req\":\"^0.5.1\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"features\":[\"net\",\"time\"],\"name\":\"tokio\",\"req\":\"^1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"tokio-native-tls\",\"optional\":true,\"req\":\"^0.3.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"tokio-rustls\",\"optional\":true,\"req\":\"^0.24\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"tokio-socks\",\"optional\":true,\"req\":\"^0.5.1\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"codec\",\"io\"],\"name\":\"tokio-util\",\"optional\":true,\"req\":\"^0.7.1\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"tower-service\",\"req\":\"^0.3\"},{\"name\":\"url\",\"req\":\"^2.2\"},{\"name\":\"wasm-bindgen\",\"req\":\"^0.2.68\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"features\":[\"serde-serialize\"],\"kind\":\"dev\",\"name\":\"wasm-bindgen\",\"req\":\"^0.2.68\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"wasm-bindgen-futures\",\"req\":\"^0.4.18\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"wasm-streams\",\"optional\":true,\"req\":\"^0.4\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"features\":[\"AbortController\",\"AbortSignal\",\"Headers\",\"Request\",\"RequestInit\",\"RequestMode\",\"Response\",\"Window\",\"FormData\",\"Blob\",\"BlobPropertyBag\",\"ServiceWorkerGlobalScope\",\"RequestCredentials\",\"File\",\"ReadableStream\"],\"name\":\"web-sys\",\"req\":\"^0.3.25\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^0.25\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"winreg\",\"req\":\"^0.50.0\",\"target\":\"cfg(windows)\"}],\"features\":{\"__internal_proxy_sys_no_cache\":[],\"__rustls\":[\"hyper-rustls\",\"tokio-rustls\",\"rustls\",\"__tls\"],\"__tls\":[\"dep:rustls-pemfile\"],\"blocking\":[\"futures-util/io\",\"tokio/sync\"],\"brotli\":[\"async-compression\",\"async-compression/brotli\",\"tokio-util\"],\"cookies\":[\"cookie_crate\",\"cookie_store\"],\"default\":[\"default-tls\"],\"default-tls\":[\"hyper-tls\",\"native-tls-crate\",\"__tls\",\"tokio-native-tls\"],\"deflate\":[\"async-compression\",\"async-compression/zlib\",\"tokio-util\"],\"gzip\":[\"async-compression\",\"async-compression/gzip\",\"tokio-util\"],\"hickory-dns\":[\"hickory-resolver\"],\"http3\":[\"rustls-tls-manual-roots\",\"h3\",\"h3-quinn\",\"quinn\",\"futures-channel\"],\"json\":[\"serde_json\"],\"multipart\":[\"mime_guess\"],\"native-tls\":[\"default-tls\"],\"native-tls-alpn\":[\"native-tls\",\"native-tls-crate/alpn\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\"],\"rustls-tls\":[\"rustls-tls-webpki-roots\"],\"rustls-tls-manual-roots\":[\"__rustls\"],\"rustls-tls-native-roots\":[\"rustls-native-certs\",\"__rustls\"],\"rustls-tls-webpki-roots\":[\"webpki-roots\",\"__rustls\"],\"socks\":[\"tokio-socks\"],\"stream\":[\"tokio/fs\",\"tokio-util\",\"wasm-streams\"],\"trust-dns\":[\"hickory-dns\"]}}", "reqwest_0.12.28": "{\"dependencies\":[{\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"brotli_crate\",\"package\":\"brotli\",\"req\":\"^8\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"bytes\",\"req\":\"^1.2\"},{\"name\":\"cookie_crate\",\"optional\":true,\"package\":\"cookie\",\"req\":\"^0.18.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"cookie_store\",\"optional\":true,\"req\":\"^0.22.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"encoding_rs\",\"optional\":true,\"req\":\"^0.8\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0.13\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"futures-channel\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.28\"},{\"default_features\":false,\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3.28\"},{\"default_features\":false,\"features\":[\"std\",\"alloc\"],\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.28\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"h2\",\"optional\":true,\"req\":\"^0.4\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"h3\",\"optional\":true,\"req\":\"^0.0.8\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"h3-quinn\",\"optional\":true,\"req\":\"^0.0.10\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"tokio\"],\"name\":\"hickory-resolver\",\"optional\":true,\"req\":\"^0.25\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"http\",\"req\":\"^1.1\"},{\"name\":\"http-body\",\"req\":\"^1\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"http-body-util\",\"req\":\"^0.1.2\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"http1\",\"client\"],\"name\":\"hyper\",\"req\":\"^1.1\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"http1\",\"http2\",\"client\",\"server\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1.1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"http1\",\"tls12\"],\"name\":\"hyper-rustls\",\"optional\":true,\"req\":\"^0.27.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"hyper-tls\",\"optional\":true,\"req\":\"^0.6\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"http1\",\"client\",\"client-legacy\",\"client-proxy\",\"tokio\"],\"name\":\"hyper-util\",\"req\":\"^0.1.12\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"http1\",\"http2\",\"client\",\"client-legacy\",\"server-auto\",\"server-graceful\",\"tokio\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1.12\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"js-sys\",\"req\":\"^0.3.77\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0\"},{\"name\":\"log\",\"req\":\"^0.4.17\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"mime\",\"optional\":true,\"req\":\"^0.3.16\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"name\":\"mime_guess\",\"optional\":true,\"req\":\"^2.0\"},{\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\",\"req\":\"^0.2.10\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"^1.0\"},{\"name\":\"once_cell\",\"optional\":true,\"req\":\"^1.18\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"percent-encoding\",\"req\":\"^2.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"rustls\",\"runtime-tokio\"],\"name\":\"quinn\",\"optional\":true,\"req\":\"^0.11.1\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"std\",\"tls12\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.4\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.8.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"std\"],\"name\":\"rustls-pki-types\",\"optional\":true,\"req\":\"^1.9.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"serde\",\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"serde_json\",\"req\":\"^1.0\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde_urlencoded\",\"req\":\"^0.7.1\"},{\"features\":[\"futures\"],\"name\":\"sync_wrapper\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"net\",\"time\"],\"name\":\"tokio\",\"req\":\"^1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"tokio-native-tls\",\"optional\":true,\"req\":\"^0.3.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"tls12\"],\"name\":\"tokio-rustls\",\"optional\":true,\"req\":\"^0.26\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"io\"],\"name\":\"tokio-util\",\"optional\":true,\"req\":\"^0.7.9\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"retry\",\"timeout\",\"util\"],\"name\":\"tower\",\"req\":\"^0.5.2\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"limit\"],\"kind\":\"dev\",\"name\":\"tower\",\"req\":\"^0.5.2\"},{\"default_features\":false,\"features\":[\"follow-redirect\"],\"name\":\"tower-http\",\"req\":\"^0.6.8\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"tower-service\",\"req\":\"^0.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"url\",\"req\":\"^2.4\"},{\"name\":\"wasm-bindgen\",\"req\":\"^0.2.89\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"features\":[\"serde-serialize\"],\"kind\":\"dev\",\"name\":\"wasm-bindgen\",\"req\":\"^0.2.89\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"wasm-bindgen-futures\",\"req\":\"^0.4.18\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"wasm-streams\",\"optional\":true,\"req\":\"^0.4\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"features\":[\"AbortController\",\"AbortSignal\",\"Headers\",\"Request\",\"RequestInit\",\"RequestMode\",\"Response\",\"Window\",\"FormData\",\"Blob\",\"BlobPropertyBag\",\"ServiceWorkerGlobalScope\",\"RequestCredentials\",\"File\",\"ReadableStream\",\"RequestCache\"],\"name\":\"web-sys\",\"req\":\"^0.3.28\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^1\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"zstd_crate\",\"package\":\"zstd\",\"req\":\"^0.13\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"}],\"features\":{\"__rustls\":[\"dep:hyper-rustls\",\"dep:tokio-rustls\",\"dep:rustls\",\"__tls\"],\"__rustls-ring\":[\"hyper-rustls?/ring\",\"tokio-rustls?/ring\",\"rustls?/ring\",\"quinn?/ring\"],\"__tls\":[\"dep:rustls-pki-types\",\"tokio/io-util\"],\"blocking\":[\"dep:futures-channel\",\"futures-channel?/sink\",\"dep:futures-util\",\"futures-util?/io\",\"futures-util?/sink\",\"tokio/sync\"],\"brotli\":[\"tower-http/decompression-br\"],\"charset\":[\"dep:encoding_rs\",\"dep:mime\"],\"cookies\":[\"dep:cookie_crate\",\"dep:cookie_store\"],\"default\":[\"default-tls\",\"charset\",\"http2\",\"system-proxy\"],\"default-tls\":[\"dep:hyper-tls\",\"dep:native-tls-crate\",\"__tls\",\"dep:tokio-native-tls\"],\"deflate\":[\"tower-http/decompression-deflate\"],\"gzip\":[\"tower-http/decompression-gzip\"],\"hickory-dns\":[\"dep:hickory-resolver\",\"dep:once_cell\"],\"http2\":[\"h2\",\"hyper/http2\",\"hyper-util/http2\",\"hyper-rustls?/http2\"],\"http3\":[\"rustls-tls-manual-roots\",\"dep:h3\",\"dep:h3-quinn\",\"dep:quinn\",\"tokio/macros\"],\"json\":[\"dep:serde_json\"],\"macos-system-configuration\":[\"system-proxy\"],\"multipart\":[\"dep:mime_guess\",\"dep:futures-util\"],\"native-tls\":[\"default-tls\"],\"native-tls-alpn\":[\"native-tls\",\"native-tls-crate?/alpn\",\"hyper-tls?/alpn\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate?/vendored\"],\"rustls-tls\":[\"rustls-tls-webpki-roots\"],\"rustls-tls-manual-roots\":[\"rustls-tls-manual-roots-no-provider\",\"__rustls-ring\"],\"rustls-tls-manual-roots-no-provider\":[\"__rustls\"],\"rustls-tls-native-roots\":[\"rustls-tls-native-roots-no-provider\",\"__rustls-ring\"],\"rustls-tls-native-roots-no-provider\":[\"dep:rustls-native-certs\",\"hyper-rustls?/native-tokio\",\"__rustls\"],\"rustls-tls-no-provider\":[\"rustls-tls-manual-roots-no-provider\"],\"rustls-tls-webpki-roots\":[\"rustls-tls-webpki-roots-no-provider\",\"__rustls-ring\"],\"rustls-tls-webpki-roots-no-provider\":[\"dep:webpki-roots\",\"hyper-rustls?/webpki-tokio\",\"__rustls\"],\"socks\":[],\"stream\":[\"tokio/fs\",\"dep:futures-util\",\"dep:tokio-util\",\"dep:wasm-streams\"],\"system-proxy\":[\"hyper-util/client-proxy-system\"],\"trust-dns\":[],\"zstd\":[\"tower-http/decompression-zstd\"]}}", "resolv-conf_0.7.6": "{\"dependencies\":[],\"features\":{\"system\":[]}}", "ring_0.17.14": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.2.8\"},{\"default_features\":false,\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"name\":\"getrandom\",\"req\":\"^0.2.10\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.148\",\"target\":\"cfg(all(any(all(target_arch = \\\"aarch64\\\", target_endian = \\\"little\\\"), all(target_arch = \\\"arm\\\", target_endian = \\\"little\\\")), any(target_os = \\\"android\\\", target_os = \\\"linux\\\")))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(all(all(target_arch = \\\"aarch64\\\", target_endian = \\\"little\\\"), target_vendor = \\\"apple\\\", any(target_os = \\\"ios\\\", target_os = \\\"macos\\\", target_os = \\\"tvos\\\", target_os = \\\"visionos\\\", target_os = \\\"watchos\\\")))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.148\",\"target\":\"cfg(any(unix, windows, target_os = \\\"wasi\\\"))\"},{\"name\":\"untrusted\",\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.37\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Threading\"],\"name\":\"windows-sys\",\"req\":\"^0.52\",\"target\":\"cfg(all(all(target_arch = \\\"aarch64\\\", target_endian = \\\"little\\\"), target_os = \\\"windows\\\"))\"}],\"features\":{\"alloc\":[],\"default\":[\"alloc\",\"dev_urandom_fallback\"],\"dev_urandom_fallback\":[],\"less-safe-getrandom-custom-or-rdrand\":[],\"less-safe-getrandom-espidf\":[],\"slow_tests\":[],\"std\":[\"alloc\"],\"test_logging\":[],\"unstable-testing-arm-no-hw\":[],\"unstable-testing-arm-no-neon\":[],\"wasm32_unknown_unknown_js\":[\"getrandom/js\"]}}", @@ -1197,6 +1224,7 @@ "rustix_0.38.44": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bitflags\",\"req\":\"^2.4.0\"},{\"name\":\"compiler_builtins\",\"optional\":true,\"req\":\"^0.1.49\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\",\"target\":\"cfg(all(criterion, not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"itoa\",\"optional\":true,\"req\":\"^1.0.13\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.161\",\"target\":\"cfg(all(not(windows), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", target_arch = \\\"s390x\\\"), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.161\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", target_arch = \\\"s390x\\\"), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.161\"},{\"default_features\":false,\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(all(not(windows), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", target_arch = \\\"s390x\\\"), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(windows)\"},{\"default_features\":false,\"name\":\"libc_errno\",\"optional\":true,\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", target_arch = \\\"s390x\\\"), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\"},{\"default_features\":false,\"features\":[\"general\",\"ioctl\",\"no_std\"],\"name\":\"linux-raw-sys\",\"req\":\"^0.4.14\",\"target\":\"cfg(all(any(target_os = \\\"android\\\", target_os = \\\"linux\\\"), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", target_arch = \\\"s390x\\\"), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"features\":[\"general\",\"errno\",\"ioctl\",\"no_std\",\"elf\"],\"name\":\"linux-raw-sys\",\"req\":\"^0.4.14\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", target_arch = \\\"s390x\\\"), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"kind\":\"dev\",\"name\":\"memoffset\",\"req\":\"^0.9.0\"},{\"name\":\"once_cell\",\"optional\":true,\"req\":\"^1.5.2\",\"target\":\"cfg(any(target_os = \\\"android\\\", target_os = \\\"linux\\\"))\"},{\"name\":\"rustc-std-workspace-alloc\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.5.0\"},{\"features\":[\"Win32_Foundation\",\"Win32_Networking_WinSock\",\"Win32_NetworkManagement_IpHelper\",\"Win32_System_Threading\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <=0.59\",\"target\":\"cfg(windows)\"}],\"features\":{\"all-apis\":[\"event\",\"fs\",\"io_uring\",\"mm\",\"mount\",\"net\",\"param\",\"pipe\",\"process\",\"procfs\",\"pty\",\"rand\",\"runtime\",\"shm\",\"stdio\",\"system\",\"termios\",\"thread\",\"time\"],\"alloc\":[],\"cc\":[],\"default\":[\"std\",\"use-libc-auxv\"],\"event\":[],\"fs\":[],\"io_uring\":[\"event\",\"fs\",\"net\",\"linux-raw-sys/io_uring\"],\"libc-extra-traits\":[\"libc?/extra_traits\"],\"linux_4_11\":[],\"linux_latest\":[\"linux_4_11\"],\"mm\":[],\"mount\":[],\"net\":[\"linux-raw-sys/net\",\"linux-raw-sys/netlink\",\"linux-raw-sys/if_ether\",\"linux-raw-sys/xdp\"],\"param\":[\"fs\"],\"pipe\":[],\"process\":[\"linux-raw-sys/prctl\"],\"procfs\":[\"once_cell\",\"itoa\",\"fs\"],\"pty\":[\"itoa\",\"fs\"],\"rand\":[],\"runtime\":[\"linux-raw-sys/prctl\"],\"rustc-dep-of-std\":[\"core\",\"rustc-std-workspace-alloc\",\"compiler_builtins\",\"linux-raw-sys/rustc-dep-of-std\",\"bitflags/rustc-dep-of-std\",\"compiler_builtins?/rustc-dep-of-std\"],\"shm\":[\"fs\"],\"std\":[\"bitflags/std\",\"alloc\",\"libc?/std\",\"libc_errno?/std\",\"libc-extra-traits\"],\"stdio\":[],\"system\":[\"linux-raw-sys/system\"],\"termios\":[],\"thread\":[\"linux-raw-sys/prctl\"],\"time\":[],\"try_close\":[],\"use-explicitly-provided-auxv\":[],\"use-libc\":[\"libc_errno\",\"libc\",\"libc-extra-traits\"],\"use-libc-auxv\":[]}}", "rustix_1.1.3": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bitflags\",\"req\":\"^2.4.0\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\",\"target\":\"cfg(all(criterion, not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.177\",\"target\":\"cfg(all(not(windows), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.177\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.171\"},{\"default_features\":false,\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(all(not(windows), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(windows)\"},{\"default_features\":false,\"name\":\"libc_errno\",\"optional\":true,\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\"},{\"default_features\":false,\"features\":[\"general\",\"ioctl\",\"no_std\"],\"name\":\"linux-raw-sys\",\"req\":\"^0.11.0\",\"target\":\"cfg(all(any(target_os = \\\"linux\\\", target_os = \\\"android\\\"), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"features\":[\"auxvec\",\"general\",\"errno\",\"ioctl\",\"no_std\",\"elf\"],\"name\":\"linux-raw-sys\",\"req\":\"^0.11.0\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"kind\":\"dev\",\"name\":\"memoffset\",\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.20.3\",\"target\":\"cfg(windows)\"},{\"name\":\"rustc-std-workspace-alloc\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.5.0\"},{\"features\":[\"Win32_Foundation\",\"Win32_Networking_WinSock\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{\"all-apis\":[\"event\",\"fs\",\"io_uring\",\"mm\",\"mount\",\"net\",\"param\",\"pipe\",\"process\",\"pty\",\"rand\",\"runtime\",\"shm\",\"stdio\",\"system\",\"termios\",\"thread\",\"time\"],\"alloc\":[],\"default\":[\"std\"],\"event\":[],\"fs\":[],\"io_uring\":[\"event\",\"fs\",\"net\",\"thread\",\"linux-raw-sys/io_uring\"],\"linux_4_11\":[],\"linux_5_1\":[\"linux_4_11\"],\"linux_5_11\":[\"linux_5_1\"],\"linux_latest\":[\"linux_5_11\"],\"mm\":[],\"mount\":[],\"net\":[\"linux-raw-sys/net\",\"linux-raw-sys/netlink\",\"linux-raw-sys/if_ether\",\"linux-raw-sys/xdp\"],\"param\":[],\"pipe\":[],\"process\":[\"linux-raw-sys/prctl\"],\"pty\":[\"fs\"],\"rand\":[],\"runtime\":[\"linux-raw-sys/prctl\"],\"rustc-dep-of-std\":[\"core\",\"rustc-std-workspace-alloc\",\"linux-raw-sys/rustc-dep-of-std\",\"bitflags/rustc-dep-of-std\"],\"shm\":[\"fs\"],\"std\":[\"bitflags/std\",\"alloc\",\"libc?/std\",\"libc_errno?/std\"],\"stdio\":[],\"system\":[\"linux-raw-sys/system\"],\"termios\":[],\"thread\":[\"linux-raw-sys/prctl\"],\"time\":[],\"try_close\":[],\"use-explicitly-provided-auxv\":[],\"use-libc\":[\"libc_errno\",\"libc\"],\"use-libc-auxv\":[]}}", "rustls-native-certs_0.8.3": "{\"dependencies\":[{\"name\":\"openssl-probe\",\"req\":\"^0.2\",\"target\":\"cfg(all(unix, not(target_os = \\\"macos\\\")))\"},{\"features\":[\"std\"],\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.10\"},{\"kind\":\"dev\",\"name\":\"ring\",\"req\":\"^0.17\"},{\"kind\":\"dev\",\"name\":\"rustls\",\"req\":\"^0.23\"},{\"kind\":\"dev\",\"name\":\"rustls-webpki\",\"req\":\"^0.103\"},{\"name\":\"schannel\",\"req\":\"^0.1\",\"target\":\"cfg(windows)\"},{\"name\":\"security-framework\",\"req\":\"^3\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^3\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.5\"},{\"kind\":\"dev\",\"name\":\"untrusted\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.18\"}],\"features\":{}}", + "rustls-pemfile_1.0.4": "{\"dependencies\":[{\"name\":\"base64\",\"req\":\"^0.21\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"}],\"features\":{}}", "rustls-pki-types_1.14.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"crabgrind\",\"req\":\"=0.1.9\",\"target\":\"cfg(all(target_os = \\\"linux\\\", target_arch = \\\"x86_64\\\"))\"},{\"name\":\"web-time\",\"optional\":true,\"req\":\"^1\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[\"dep:zeroize\"],\"default\":[\"alloc\"],\"std\":[\"alloc\"],\"web\":[\"web-time\"]}}", "rustls-webpki_0.103.9": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.14\"},{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"kind\":\"dev\",\"name\":\"bzip2\",\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.17.2\"},{\"default_features\":false,\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.12\"},{\"default_features\":false,\"features\":[\"aws_lc_rs\"],\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14.2\"},{\"default_features\":false,\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"untrusted\",\"req\":\"^0.9\"}],\"features\":{\"alloc\":[\"ring?/alloc\",\"pki-types/alloc\"],\"aws-lc-rs\":[\"dep:aws-lc-rs\",\"aws-lc-rs/aws-lc-sys\",\"aws-lc-rs/prebuilt-nasm\"],\"aws-lc-rs-fips\":[\"dep:aws-lc-rs\",\"aws-lc-rs/fips\"],\"aws-lc-rs-unstable\":[\"aws-lc-rs\",\"aws-lc-rs/unstable\"],\"default\":[\"std\"],\"ring\":[\"dep:ring\"],\"std\":[\"alloc\",\"pki-types/std\"]}}", "rustls_0.23.36": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.14\"},{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"brotli\",\"optional\":true,\"req\":\"^8\"},{\"name\":\"brotli-decompressor\",\"optional\":true,\"req\":\"^5.0.0\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"default_features\":false,\"features\":[\"default-hasher\",\"inline-more\"],\"name\":\"hashbrown\",\"optional\":true,\"req\":\"^0.15\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.8\"},{\"kind\":\"dev\",\"name\":\"log\",\"req\":\"^0.4.8\"},{\"kind\":\"dev\",\"name\":\"macro_rules_attribute\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"num-bigint\",\"req\":\"^0.4.4\"},{\"default_features\":false,\"features\":[\"alloc\",\"race\"],\"name\":\"once_cell\",\"req\":\"^1.16\"},{\"features\":[\"alloc\"],\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.12\"},{\"default_features\":false,\"features\":[\"pem\",\"aws_lc_rs\"],\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14\"},{\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17\"},{\"kind\":\"build\",\"name\":\"rustversion\",\"optional\":true,\"req\":\"^1.0.6\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2.5.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.6\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.103.5\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.17\"},{\"name\":\"zeroize\",\"req\":\"^1.8\"},{\"name\":\"zlib-rs\",\"optional\":true,\"req\":\"^0.5\"}],\"features\":{\"aws-lc-rs\":[\"aws_lc_rs\"],\"aws_lc_rs\":[\"dep:aws-lc-rs\",\"webpki/aws-lc-rs\",\"aws-lc-rs/aws-lc-sys\",\"aws-lc-rs/prebuilt-nasm\"],\"brotli\":[\"dep:brotli\",\"dep:brotli-decompressor\",\"std\"],\"custom-provider\":[],\"default\":[\"aws_lc_rs\",\"logging\",\"prefer-post-quantum\",\"std\",\"tls12\"],\"fips\":[\"aws_lc_rs\",\"aws-lc-rs?/fips\",\"webpki/aws-lc-rs-fips\"],\"logging\":[\"log\"],\"prefer-post-quantum\":[\"aws_lc_rs\"],\"read_buf\":[\"rustversion\",\"std\"],\"ring\":[\"dep:ring\",\"webpki/ring\"],\"std\":[\"webpki/std\",\"pki-types/std\",\"once_cell/std\"],\"tls12\":[],\"zlib\":[\"dep:zlib-rs\"]}}", @@ -1217,6 +1245,7 @@ "schemars_derive_1.2.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.2.1\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.74\"},{\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"name\":\"serde_derive_internals\",\"req\":\"^0.29.1\"},{\"name\":\"syn\",\"req\":\"^2.0.46\"},{\"features\":[\"extra-traits\"],\"kind\":\"dev\",\"name\":\"syn\",\"req\":\"^2.0\"}],\"features\":{}}", "scoped-tls_1.0.1": "{\"dependencies\":[],\"features\":{}}", "scopeguard_1.2.0": "{\"dependencies\":[],\"features\":{\"default\":[\"use_std\"],\"use_std\":[]}}", + "scraper_0.18.1": "{\"dependencies\":[{\"name\":\"ahash\",\"req\":\"^0.8\"},{\"name\":\"cssparser\",\"req\":\"^0.31.0\"},{\"name\":\"ego-tree\",\"req\":\"^0.6.2\"},{\"name\":\"getopts\",\"optional\":true,\"req\":\"^0.2.21\"},{\"name\":\"html5ever\",\"req\":\"^0.26\"},{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.0.2\"},{\"name\":\"once_cell\",\"req\":\"^1.0\"},{\"name\":\"selectors\",\"req\":\"^0.25.0\"},{\"name\":\"tendril\",\"req\":\"^0.4.3\"}],\"features\":{\"atomic\":[],\"default\":[\"main\",\"errors\"],\"deterministic\":[\"indexmap\"],\"errors\":[],\"main\":[\"getopts\"]}}", "scrypt_0.11.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"rand_core\"],\"name\":\"password-hash\",\"optional\":true,\"req\":\"^0.5\"},{\"features\":[\"rand_core\"],\"kind\":\"dev\",\"name\":\"password-hash\",\"req\":\"^0.5\"},{\"name\":\"pbkdf2\",\"req\":\"^0.12\"},{\"default_features\":false,\"name\":\"salsa20\",\"req\":\"^0.10.2\"},{\"default_features\":false,\"name\":\"sha2\",\"req\":\"^0.10\"}],\"features\":{\"default\":[\"simple\",\"std\"],\"simple\":[\"password-hash\"],\"std\":[\"password-hash/std\"]}}", "sdd_3.0.10": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.6\"},{\"name\":\"loom\",\"optional\":true,\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1\"}],\"features\":{}}", "seccompiler_0.5.0": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2.153\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.27\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0.9\"}],\"features\":{\"json\":[\"serde\",\"serde_json\"]}}", @@ -1225,6 +1254,7 @@ "security-framework-sys_2.15.0": "{\"dependencies\":[{\"name\":\"core-foundation-sys\",\"req\":\"^0.8.6\"},{\"name\":\"libc\",\"req\":\"^0.2.150\"}],\"features\":{\"OSX_10_10\":[\"OSX_10_9\"],\"OSX_10_11\":[\"OSX_10_10\"],\"OSX_10_12\":[\"OSX_10_11\"],\"OSX_10_13\":[\"OSX_10_12\"],\"OSX_10_14\":[\"OSX_10_13\"],\"OSX_10_15\":[\"OSX_10_14\"],\"OSX_10_9\":[],\"default\":[\"OSX_10_12\"]}}", "security-framework_2.11.1": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2.6\"},{\"name\":\"core-foundation\",\"req\":\"^0.9.4\"},{\"name\":\"core-foundation-sys\",\"req\":\"^0.8.6\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"name\":\"libc\",\"req\":\"^0.2.139\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.20\"},{\"name\":\"num-bigint\",\"optional\":true,\"req\":\"^0.4.6\"},{\"default_features\":false,\"name\":\"security-framework-sys\",\"req\":\"^2.11.1\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.3.0\"},{\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.17\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.16\"}],\"features\":{\"OSX_10_10\":[\"OSX_10_9\",\"security-framework-sys/OSX_10_10\"],\"OSX_10_11\":[\"OSX_10_10\",\"security-framework-sys/OSX_10_11\"],\"OSX_10_12\":[\"OSX_10_11\",\"security-framework-sys/OSX_10_12\"],\"OSX_10_13\":[\"OSX_10_12\",\"security-framework-sys/OSX_10_13\",\"alpn\",\"session-tickets\",\"serial-number-bigint\"],\"OSX_10_14\":[\"OSX_10_13\",\"security-framework-sys/OSX_10_14\"],\"OSX_10_15\":[\"OSX_10_14\",\"security-framework-sys/OSX_10_15\"],\"OSX_10_9\":[\"security-framework-sys/OSX_10_9\"],\"alpn\":[],\"default\":[\"OSX_10_12\"],\"job-bless\":[],\"nightly\":[],\"serial-number-bigint\":[\"dep:num-bigint\"],\"session-tickets\":[]}}", "security-framework_3.5.1": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2.6\"},{\"name\":\"core-foundation\",\"req\":\"^0.10\"},{\"name\":\"core-foundation-sys\",\"req\":\"^0.8.6\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"name\":\"libc\",\"req\":\"^0.2.139\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.20\"},{\"default_features\":false,\"name\":\"security-framework-sys\",\"req\":\"^2.15\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.12.0\"},{\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.23\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.16\"}],\"features\":{\"OSX_10_12\":[\"security-framework-sys/OSX_10_12\"],\"OSX_10_13\":[\"OSX_10_12\",\"security-framework-sys/OSX_10_13\",\"alpn\",\"session-tickets\"],\"OSX_10_14\":[\"OSX_10_13\",\"security-framework-sys/OSX_10_14\"],\"OSX_10_15\":[\"OSX_10_14\",\"security-framework-sys/OSX_10_15\"],\"alpn\":[],\"default\":[\"OSX_10_12\"],\"job-bless\":[],\"nightly\":[],\"session-tickets\":[],\"sync-keychain\":[\"OSX_10_13\"]}}", + "selectors_0.25.0": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2\"},{\"name\":\"cssparser\",\"req\":\"^0.31\"},{\"default_features\":false,\"features\":[\"add\",\"add_assign\"],\"name\":\"derive_more\",\"req\":\"^0.99\"},{\"name\":\"fxhash\",\"req\":\"^0.2\"},{\"name\":\"log\",\"req\":\"^0.4\"},{\"name\":\"new_debug_unreachable\",\"req\":\"^1\"},{\"name\":\"phf\",\"req\":\"^0.10\"},{\"kind\":\"build\",\"name\":\"phf_codegen\",\"req\":\"^0.10\"},{\"name\":\"precomputed-hash\",\"req\":\"^0.1\"},{\"name\":\"servo_arc\",\"req\":\"^0.3\"},{\"name\":\"smallvec\",\"req\":\"^1.0\"}],\"features\":{\"bench\":[]}}", "self_cell_0.10.3": "{\"dependencies\":[{\"name\":\"new_self_cell\",\"package\":\"self_cell\",\"req\":\"^1\"}],\"features\":{\"old_rust\":[\"new_self_cell/old_rust\"]}}", "self_cell_1.2.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"=1.1.0\"},{\"name\":\"rustversion\",\"optional\":true,\"req\":\">=1\"}],\"features\":{\"old_rust\":[\"rustversion\"]}}", "semver_1.0.27": "{\"dependencies\":[{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"package\":\"serde_core\",\"req\":\"^1.0.220\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\",\"target\":\"cfg(any())\"}],\"features\":{\"default\":[\"std\"],\"serde\":[\"dep:serde\"],\"std\":[]}}", @@ -1253,6 +1283,7 @@ "serial2_0.2.33": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"assert2\",\"req\":\"^0.3.11\"},{\"name\":\"cfg-if\",\"req\":\"^1.0.0\",\"target\":\"cfg(unix)\"},{\"name\":\"libc\",\"req\":\"^0.2.109\",\"target\":\"cfg(unix)\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.108\"},{\"features\":[\"commapi\",\"fileapi\",\"handleapi\",\"ioapiset\",\"std\",\"synchapi\",\"winbase\",\"winerror\",\"winreg\"],\"name\":\"winapi\",\"req\":\"^0.3.9\",\"target\":\"cfg(windows)\"}],\"features\":{\"doc\":[],\"doc-cfg\":[],\"rs4xx\":[],\"serde\":[\"dep:serde\"],\"unix\":[],\"windows\":[]}}", "serial_test_3.3.1": "{\"dependencies\":[{\"name\":\"document-features\",\"optional\":true,\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"env_logger\",\"optional\":true,\"req\":\">=0.6.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"fslock\",\"optional\":true,\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"futures-executor\",\"optional\":true,\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"use_std\"],\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\">=0.4\"},{\"name\":\"log\",\"optional\":true,\"req\":\">=0.4.4\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"once_cell\",\"req\":\"^1.19\"},{\"default_features\":false,\"name\":\"parking_lot\",\"req\":\"^0.12\"},{\"default_features\":false,\"name\":\"scc\",\"req\":\"^2\"},{\"name\":\"serial_test_derive\",\"req\":\"~3.3.1\"}],\"features\":{\"async\":[\"dep:futures-executor\",\"dep:futures-util\",\"serial_test_derive/async\"],\"default\":[\"logging\",\"async\"],\"docsrs\":[\"dep:document-features\"],\"file_locks\":[\"dep:fslock\"],\"logging\":[\"dep:log\"],\"test_logging\":[\"logging\",\"dep:env_logger\",\"serial_test_derive/test_logging\"]}}", "serial_test_derive_3.3.1": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\">=0.6.1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"prettyplease\",\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"proc-macro\"],\"name\":\"proc-macro2\",\"req\":\"^1.0.60\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"full\",\"printing\",\"parsing\",\"clone-impls\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{\"async\":[],\"default\":[],\"file_locks\":[],\"test_logging\":[]}}", + "servo_arc_0.3.0": "{\"dependencies\":[{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"stable_deref_trait\",\"req\":\"^1.0.0\"}],\"features\":{\"gecko_refcount_logging\":[],\"servo\":[\"serde\"]}}", "sha1_0.10.6": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"name\":\"cpufeatures\",\"req\":\"^0.2\",\"target\":\"cfg(any(target_arch = \\\"aarch64\\\", target_arch = \\\"x86\\\", target_arch = \\\"x86_64\\\"))\"},{\"name\":\"digest\",\"req\":\"^0.10.7\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"digest\",\"req\":\"^0.10.7\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.2.2\"},{\"name\":\"sha1-asm\",\"optional\":true,\"req\":\"^0.5\",\"target\":\"cfg(any(target_arch = \\\"aarch64\\\", target_arch = \\\"x86\\\", target_arch = \\\"x86_64\\\"))\"}],\"features\":{\"asm\":[\"sha1-asm\"],\"compress\":[],\"default\":[\"std\"],\"force-soft\":[],\"loongarch64_asm\":[],\"oid\":[\"digest/oid\"],\"std\":[\"digest/std\"]}}", "sha1_smol_1.0.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"openssl\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.4\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"alloc\":[],\"std\":[\"alloc\"]}}", "sha2_0.10.9": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"name\":\"cpufeatures\",\"req\":\"^0.2\",\"target\":\"cfg(any(target_arch = \\\"aarch64\\\", target_arch = \\\"x86_64\\\", target_arch = \\\"x86\\\"))\"},{\"name\":\"digest\",\"req\":\"^0.10.7\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"digest\",\"req\":\"^0.10.7\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.2.2\"},{\"name\":\"sha2-asm\",\"optional\":true,\"req\":\"^0.6.1\",\"target\":\"cfg(any(target_arch = \\\"aarch64\\\", target_arch = \\\"x86_64\\\", target_arch = \\\"x86\\\"))\"}],\"features\":{\"asm\":[\"sha2-asm\"],\"asm-aarch64\":[\"asm\"],\"compress\":[],\"default\":[\"std\"],\"force-soft\":[],\"force-soft-compact\":[],\"loongarch64_asm\":[],\"oid\":[\"digest/oid\"],\"std\":[\"digest/std\"]}}", @@ -1267,6 +1298,7 @@ "simd-adler32_0.3.8": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"adler\",\"req\":\"^1.0.2\"},{\"kind\":\"dev\",\"name\":\"adler32\",\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"}],\"features\":{\"const-generics\":[],\"default\":[\"std\",\"const-generics\"],\"nightly\":[],\"std\":[]}}", "simdutf8_0.1.5": "{\"dependencies\":[],\"features\":{\"aarch64_neon\":[],\"aarch64_neon_prefetch\":[],\"default\":[\"std\"],\"hints\":[],\"public_imp\":[],\"std\":[]}}", "similar_2.7.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bstr\",\"optional\":true,\"req\":\"^1.5.0\"},{\"kind\":\"dev\",\"name\":\"console\",\"req\":\"^0.15.0\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.10.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.130\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.68\"},{\"name\":\"unicode-segmentation\",\"optional\":true,\"req\":\"^1.7.1\"},{\"name\":\"web-time\",\"optional\":true,\"req\":\"^1.1\"}],\"features\":{\"bytes\":[\"bstr\",\"text\"],\"default\":[\"text\"],\"inline\":[\"text\"],\"text\":[],\"unicode\":[\"text\",\"unicode-segmentation\",\"bstr?/unicode\",\"bstr?/std\"],\"wasm32_web_time\":[\"web-time\"]}}", + "siphasher_0.3.11": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"serde_no_std\":[\"serde/alloc\"],\"serde_std\":[\"std\",\"serde/std\"],\"std\":[]}}", "siphasher_1.0.2": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"serde_no_std\":[\"serde/alloc\"],\"serde_std\":[\"std\",\"serde/std\"],\"std\":[]}}", "slab_0.4.12": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.95\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "smallvec_1.15.1": "{\"dependencies\":[{\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"bincode\",\"optional\":true,\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"bincode1\",\"package\":\"bincode\",\"req\":\"^1.0.1\"},{\"kind\":\"dev\",\"name\":\"debugger_test\",\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"debugger_test_parser\",\"req\":\"^0.1.0\"},{\"default_features\":false,\"name\":\"malloc_size_of\",\"optional\":true,\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"unty\",\"optional\":true,\"req\":\"^0.0.4\"}],\"features\":{\"const_generics\":[],\"const_new\":[\"const_generics\"],\"debugger_visualizer\":[],\"drain_filter\":[],\"drain_keep_rest\":[\"drain_filter\"],\"impl_bincode\":[\"bincode\",\"unty\"],\"may_dangle\":[],\"specialization\":[],\"union\":[],\"write\":[]}}", @@ -1293,6 +1325,7 @@ "stop-words_0.9.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"human_regex\",\"req\":\"^0.3.0\"},{\"kind\":\"build\",\"name\":\"serde_json\",\"req\":\"^1\"}],\"features\":{\"constructed\":[],\"default\":[\"iso\"],\"iso\":[],\"nltk\":[],\"unimplemented\":[]}}", "streaming-iterator_0.1.9": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"std\":[\"alloc\"]}}", "string_cache_0.8.9": "{\"dependencies\":[{\"default_features\":false,\"name\":\"malloc_size_of\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"new_debug_unreachable\",\"req\":\"^1.0.2\"},{\"name\":\"parking_lot\",\"req\":\"^0.12\"},{\"name\":\"phf_shared\",\"req\":\"^0.11\"},{\"name\":\"precomputed-hash\",\"req\":\"^0.1\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"default\":[\"serde_support\"],\"serde_support\":[\"serde\"]}}", + "string_cache_codegen_0.5.4": "{\"dependencies\":[{\"name\":\"phf_generator\",\"req\":\"^0.11\"},{\"name\":\"phf_shared\",\"req\":\"^0.11\"},{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"}],\"features\":{}}", "stringprep_0.1.5": "{\"dependencies\":[{\"name\":\"unicode-bidi\",\"req\":\"^0.3\"},{\"name\":\"unicode-normalization\",\"req\":\"^0.1\"},{\"name\":\"unicode-properties\",\"req\":\"^0.1.1\"}],\"features\":{}}", "strsim_0.10.0": "{\"dependencies\":[],\"features\":{}}", "strsim_0.11.1": "{\"dependencies\":[],\"features\":{}}", @@ -1305,14 +1338,18 @@ "supports-color_3.0.2": "{\"dependencies\":[{\"name\":\"is_ci\",\"req\":\"^1.2.0\"}],\"features\":{}}", "syn_1.0.109": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.46\"},{\"default_features\":false,\"name\":\"quote\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"ref-cast\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.0\"},{\"features\":[\"blocking\"],\"kind\":\"dev\",\"name\":\"reqwest\",\"req\":\"^0.11\"},{\"kind\":\"dev\",\"name\":\"syn-test-suite\",\"req\":\"^0\"},{\"kind\":\"dev\",\"name\":\"tar\",\"req\":\"^0.4.16\"},{\"kind\":\"dev\",\"name\":\"termcolor\",\"req\":\"^1.0\"},{\"name\":\"unicode-ident\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.1\"}],\"features\":{\"clone-impls\":[],\"default\":[\"derive\",\"parsing\",\"printing\",\"clone-impls\",\"proc-macro\"],\"derive\":[],\"extra-traits\":[],\"fold\":[],\"full\":[],\"parsing\":[],\"printing\":[\"quote\"],\"proc-macro\":[\"proc-macro2/proc-macro\",\"quote/proc-macro\"],\"test\":[\"syn-test-suite/all-features\"],\"visit\":[],\"visit-mut\":[]}}", "syn_2.0.114": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1\",\"target\":\"cfg(not(miri))\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.91\"},{\"default_features\":false,\"name\":\"quote\",\"optional\":true,\"req\":\"^1.0.35\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1\",\"target\":\"cfg(not(miri))\"},{\"kind\":\"dev\",\"name\":\"ref-cast\",\"req\":\"^1\"},{\"features\":[\"blocking\"],\"kind\":\"dev\",\"name\":\"reqwest\",\"req\":\"^0.13\",\"target\":\"cfg(not(miri))\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"syn-test-suite\",\"req\":\"^0\"},{\"kind\":\"dev\",\"name\":\"tar\",\"req\":\"^0.4.16\",\"target\":\"cfg(not(miri))\"},{\"kind\":\"dev\",\"name\":\"termcolor\",\"req\":\"^1\"},{\"name\":\"unicode-ident\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.3.2\",\"target\":\"cfg(not(miri))\"}],\"features\":{\"clone-impls\":[],\"default\":[\"derive\",\"parsing\",\"printing\",\"clone-impls\",\"proc-macro\"],\"derive\":[],\"extra-traits\":[],\"fold\":[],\"full\":[],\"parsing\":[],\"printing\":[\"dep:quote\"],\"proc-macro\":[\"proc-macro2/proc-macro\",\"quote?/proc-macro\"],\"test\":[\"syn-test-suite/all-features\"],\"visit\":[],\"visit-mut\":[]}}", + "sync_wrapper_0.1.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-core\",\"optional\":true,\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"pin-project-lite\",\"req\":\"^0.2.7\"}],\"features\":{\"futures\":[\"futures-core\"]}}", "sync_wrapper_1.0.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-core\",\"optional\":true,\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"pin-project-lite\",\"req\":\"^0.2.7\"}],\"features\":{\"futures\":[\"futures-core\"]}}", "synstructure_0.13.2": "{\"dependencies\":[{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.60\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"derive\",\"parsing\",\"printing\",\"clone-impls\",\"visit\",\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"synstructure_test_traits\",\"req\":\"^0.1\"}],\"features\":{\"default\":[\"proc-macro\"],\"proc-macro\":[\"proc-macro2/proc-macro\",\"syn/proc-macro\",\"quote/proc-macro\"]}}", "syntect_5.3.0": "{\"dependencies\":[{\"name\":\"bincode\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"name\":\"fancy-regex\",\"optional\":true,\"req\":\"^0.16.2\"},{\"name\":\"flate2\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"fnv\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"getopts\",\"req\":\"^0.2\"},{\"name\":\"once_cell\",\"req\":\"^1.8\"},{\"default_features\":false,\"name\":\"onig\",\"optional\":true,\"req\":\"^6.5.1\"},{\"name\":\"plist\",\"optional\":true,\"req\":\"^1.3\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"public-api\",\"req\":\"^0.50.1\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.0\"},{\"name\":\"regex-syntax\",\"optional\":true,\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rustdoc-json\",\"req\":\"^0.9.7\"},{\"kind\":\"dev\",\"name\":\"rustup-toolchain\",\"req\":\"^0.1.5\"},{\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_derive\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"thiserror\",\"req\":\"^2.0.12\"},{\"name\":\"walkdir\",\"req\":\"^2.0\"},{\"name\":\"yaml-rust\",\"optional\":true,\"req\":\"^0.4.5\"}],\"features\":{\"default\":[\"default-onig\"],\"default-fancy\":[\"parsing\",\"default-syntaxes\",\"default-themes\",\"html\",\"plist-load\",\"yaml-load\",\"dump-load\",\"dump-create\",\"regex-fancy\"],\"default-onig\":[\"parsing\",\"default-syntaxes\",\"default-themes\",\"html\",\"plist-load\",\"yaml-load\",\"dump-load\",\"dump-create\",\"regex-onig\"],\"default-syntaxes\":[\"parsing\",\"dump-load\"],\"default-themes\":[\"dump-load\"],\"dump-create\":[\"flate2\",\"bincode\"],\"dump-load\":[\"flate2\",\"bincode\"],\"html\":[\"parsing\"],\"metadata\":[\"parsing\",\"plist-load\",\"dep:serde_json\"],\"parsing\":[\"regex-syntax\",\"fnv\",\"dump-create\",\"dump-load\"],\"plist-load\":[\"plist\",\"dep:serde_json\"],\"regex-fancy\":[\"fancy-regex\"],\"regex-onig\":[\"onig\"],\"yaml-load\":[\"yaml-rust\",\"parsing\"]}}", "sys-locale_0.3.2": "{\"dependencies\":[{\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", not(unix)))\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"name\":\"wasm-bindgen\",\"optional\":true,\"req\":\"^0.2\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", not(unix)))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", not(unix)))\"},{\"features\":[\"Window\",\"WorkerGlobalScope\",\"Navigator\",\"WorkerNavigator\"],\"name\":\"web-sys\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", not(unix)))\"}],\"features\":{\"js\":[\"js-sys\",\"wasm-bindgen\",\"web-sys\"]}}", + "system-configuration-sys_0.5.0": "{\"dependencies\":[{\"name\":\"core-foundation-sys\",\"req\":\"^0.8\"},{\"name\":\"libc\",\"req\":\"^0.2.49\"}],\"features\":{}}", "system-configuration-sys_0.6.0": "{\"dependencies\":[{\"name\":\"core-foundation-sys\",\"req\":\"^0.8\"},{\"name\":\"libc\",\"req\":\"^0.2.149\"}],\"features\":{}}", + "system-configuration_0.5.1": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^1\"},{\"name\":\"core-foundation\",\"req\":\"^0.9\"},{\"name\":\"system-configuration-sys\",\"req\":\"^0.5\"}],\"features\":{}}", "system-configuration_0.6.1": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2\"},{\"name\":\"core-foundation\",\"req\":\"^0.9\"},{\"name\":\"system-configuration-sys\",\"req\":\"^0.6\"}],\"features\":{}}", "tagptr_0.2.0": "{\"dependencies\":[],\"features\":{}}", "tempfile_3.24.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"name\":\"fastrand\",\"req\":\"^2.1.1\"},{\"default_features\":false,\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.3.0\",\"target\":\"cfg(any(unix, windows, target_os = \\\"wasi\\\"))\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"once_cell\",\"req\":\"^1.19.0\"},{\"features\":[\"fs\"],\"name\":\"rustix\",\"req\":\"^1.1.3\",\"target\":\"cfg(any(unix, target_os = \\\"wasi\\\"))\"},{\"features\":[\"Win32_Storage_FileSystem\",\"Win32_Foundation\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"getrandom\"],\"nightly\":[]}}", + "tendril_0.4.3": "{\"dependencies\":[{\"name\":\"encoding\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"encoding_rs\",\"optional\":true,\"req\":\"^0.8.12\"},{\"name\":\"futf\",\"req\":\"^0.1.5\"},{\"name\":\"mac\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.4\"},{\"name\":\"utf-8\",\"req\":\"^0.7\"}],\"features\":{\"bench\":[]}}", "term_0.7.0": "{\"dependencies\":[{\"name\":\"dirs-next\",\"req\":\"^2\"},{\"name\":\"rustversion\",\"req\":\"^1\",\"target\":\"cfg(windows)\"},{\"features\":[\"consoleapi\",\"wincon\",\"handleapi\",\"fileapi\"],\"name\":\"winapi\",\"req\":\"^0.3\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[]}}", "termcolor_1.4.1": "{\"dependencies\":[{\"name\":\"winapi-util\",\"req\":\"^0.1.3\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "terminal_size_0.4.3": "{\"dependencies\":[{\"features\":[\"termios\"],\"name\":\"rustix\",\"req\":\"^1.0.1\",\"target\":\"cfg(unix)\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Console\"],\"name\":\"windows-sys\",\"req\":\"^0.60.0\",\"target\":\"cfg(windows)\"}],\"features\":{}}", @@ -1523,6 +1560,7 @@ "x25519-dalek_2.0.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"curve25519-dalek\",\"req\":\"^4\"},{\"default_features\":false,\"name\":\"rand_core\",\"req\":\"^0.6\"},{\"default_features\":false,\"features\":[\"getrandom\"],\"kind\":\"dev\",\"name\":\"rand_core\",\"req\":\"^0.6\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"zeroize_derive\"],\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[\"curve25519-dalek/alloc\",\"serde?/alloc\",\"zeroize?/alloc\"],\"default\":[\"alloc\",\"precomputed-tables\",\"zeroize\"],\"getrandom\":[\"rand_core/getrandom\"],\"precomputed-tables\":[\"curve25519-dalek/precomputed-tables\"],\"reusable_secrets\":[],\"serde\":[\"dep:serde\",\"curve25519-dalek/serde\"],\"static_secrets\":[],\"zeroize\":[\"dep:zeroize\",\"curve25519-dalek/zeroize\"]}}", "x509-parser_0.18.1": "{\"dependencies\":[{\"features\":[\"datetime\"],\"name\":\"asn1-rs\",\"req\":\"^0.7.0\"},{\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"data-encoding\",\"req\":\"^2.2.1\"},{\"features\":[\"bigint\"],\"name\":\"der-parser\",\"req\":\"^10.0\"},{\"name\":\"lazy_static\",\"req\":\"^1.4\"},{\"name\":\"nom\",\"req\":\"^7.0\"},{\"features\":[\"crypto\",\"x509\",\"x962\"],\"name\":\"oid-registry\",\"req\":\"^0.8.1\"},{\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17.12\"},{\"name\":\"rusticata-macros\",\"req\":\"^4.0\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"},{\"features\":[\"formatting\"],\"name\":\"time\",\"req\":\"^0.3.35\"}],\"features\":{\"default\":[],\"validate\":[],\"verify\":[\"ring\"],\"verify-aws\":[\"aws-lc-rs\"]}}", "xdg-home_1.3.0": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"features\":[\"Win32_Foundation\",\"Win32_UI_Shell\",\"Win32_System_Com\"],\"name\":\"windows-sys\",\"req\":\"^0.59\",\"target\":\"cfg(windows)\"}],\"features\":{}}", + "xml-rs_0.8.28": "{\"dependencies\":[],\"features\":{}}", "xz2_0.1.7": "{\"dependencies\":[{\"name\":\"futures\",\"optional\":true,\"req\":\"^0.1.26\"},{\"name\":\"lzma-sys\",\"req\":\"^0.1.18\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.0\"},{\"kind\":\"dev\",\"name\":\"tokio-core\",\"req\":\"^0.1.17\"},{\"name\":\"tokio-io\",\"optional\":true,\"req\":\"^0.1.12\"}],\"features\":{\"static\":[\"lzma-sys/static\"],\"tokio\":[\"tokio-io\",\"futures\"]}}", "yaml-rust_0.4.5": "{\"dependencies\":[{\"name\":\"linked-hash-map\",\"req\":\"^0.5.3\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^0.9\"}],\"features\":{}}", "yansi_1.0.1": "{\"dependencies\":[{\"name\":\"is-terminal\",\"optional\":true,\"req\":\"^0.4.11\"}],\"features\":{\"_nightly\":[],\"alloc\":[],\"default\":[\"std\"],\"detect-env\":[\"std\"],\"detect-tty\":[\"is-terminal\",\"std\"],\"hyperlink\":[\"std\"],\"std\":[\"alloc\"]}}", @@ -1541,11 +1579,14 @@ "zerotrie_0.2.3": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"litemap\",\"optional\":true,\"req\":\"^0.8.0\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"yoke\",\"optional\":true,\"req\":\"^0.8.0\"},{\"default_features\":false,\"name\":\"zerofrom\",\"optional\":true,\"req\":\"^0.1.3\"},{\"default_features\":false,\"name\":\"zerovec\",\"optional\":true,\"req\":\"^0.11.3\"}],\"features\":{\"alloc\":[],\"databake\":[\"dep:databake\",\"zerovec?/databake\"],\"default\":[],\"litemap\":[\"dep:litemap\",\"alloc\"],\"serde\":[\"dep:serde_core\",\"dep:litemap\",\"alloc\",\"litemap/serde\",\"zerovec?/serde\"],\"yoke\":[\"dep:yoke\"],\"zerofrom\":[\"dep:zerofrom\"]}}", "zerovec-derive_0.11.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.1\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.61\"},{\"name\":\"quote\",\"req\":\"^1.0.28\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"features\":[\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2.0.21\"}],\"features\":{}}", "zerovec_0.11.5": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"xxhash64\"],\"name\":\"twox-hash\",\"optional\":true,\"req\":\"^2.0.0\"},{\"default_features\":false,\"name\":\"yoke\",\"optional\":true,\"req\":\"^0.8.0\"},{\"default_features\":false,\"name\":\"zerofrom\",\"req\":\"^0.1.3\"},{\"default_features\":false,\"name\":\"zerovec-derive\",\"optional\":true,\"req\":\"^0.11.1\"}],\"features\":{\"alloc\":[\"serde?/alloc\"],\"databake\":[\"dep:databake\"],\"derive\":[\"dep:zerovec-derive\"],\"hashmap\":[\"dep:twox-hash\",\"alloc\"],\"serde\":[\"dep:serde\"],\"std\":[],\"yoke\":[\"dep:yoke\"]}}", + "zip_0.6.6": "{\"dependencies\":[{\"name\":\"aes\",\"optional\":true,\"req\":\"^0.8.2\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"name\":\"byteorder\",\"req\":\"^1.4.3\"},{\"name\":\"bzip2\",\"optional\":true,\"req\":\"^0.4.3\"},{\"name\":\"constant_time_eq\",\"optional\":true,\"req\":\"^0.1.5\"},{\"name\":\"crc32fast\",\"req\":\"^1.3.2\"},{\"name\":\"crossbeam-utils\",\"req\":\"^0.8.8\",\"target\":\"cfg(any(all(target_arch = \\\"arm\\\", target_pointer_width = \\\"32\\\"), target_arch = \\\"mips\\\", target_arch = \\\"powerpc\\\"))\"},{\"default_features\":false,\"name\":\"flate2\",\"optional\":true,\"req\":\"^1.0.23\"},{\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.2.5\"},{\"features\":[\"reset\"],\"name\":\"hmac\",\"optional\":true,\"req\":\"^0.12.1\"},{\"name\":\"pbkdf2\",\"optional\":true,\"req\":\"^0.11.0\"},{\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.10.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.7\"},{\"features\":[\"formatting\",\"macros\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.7\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.3.2\"},{\"name\":\"zstd\",\"optional\":true,\"req\":\"^0.11.2\"}],\"features\":{\"aes-crypto\":[\"aes\",\"constant_time_eq\",\"hmac\",\"pbkdf2\",\"sha1\"],\"default\":[\"aes-crypto\",\"bzip2\",\"deflate\",\"time\",\"zstd\"],\"deflate\":[\"flate2/rust_backend\"],\"deflate-miniz\":[\"flate2/default\"],\"deflate-zlib\":[\"flate2/zlib\"],\"unreserved\":[]}}", "zip_2.4.2": "{\"dependencies\":[{\"name\":\"aes\",\"optional\":true,\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.95\"},{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"req\":\"^1.4.1\",\"target\":\"cfg(fuzzing)\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"name\":\"bzip2\",\"optional\":true,\"req\":\"^0.5.0\"},{\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"=4.4.18\"},{\"name\":\"constant_time_eq\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"crc32fast\",\"req\":\"^1.4\"},{\"name\":\"crossbeam-utils\",\"req\":\"^0.8.21\",\"target\":\"cfg(any(all(target_arch = \\\"arm\\\", target_pointer_width = \\\"32\\\"), target_arch = \\\"mips\\\", target_arch = \\\"powerpc\\\"))\"},{\"name\":\"deflate64\",\"optional\":true,\"req\":\"^0.1.9\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"flate2\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"wasm_js\",\"std\"],\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.3.1\"},{\"features\":[\"wasm_js\",\"std\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.3.1\"},{\"features\":[\"reset\"],\"name\":\"hmac\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"indexmap\",\"req\":\"^2\"},{\"default_features\":false,\"name\":\"lzma-rs\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"memchr\",\"req\":\"^2.7\"},{\"default_features\":false,\"name\":\"nt-time\",\"optional\":true,\"req\":\"^0.10.6\"},{\"name\":\"pbkdf2\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.15\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.37\"},{\"default_features\":false,\"features\":[\"formatting\",\"macros\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.37\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.5\"},{\"name\":\"xz2\",\"optional\":true,\"req\":\"^0.1.7\"},{\"features\":[\"zeroize_derive\"],\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.8\"},{\"name\":\"zopfli\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"zstd\",\"optional\":true,\"req\":\"^0.13\"}],\"features\":{\"_all-features\":[],\"_deflate-any\":[],\"aes-crypto\":[\"aes\",\"constant_time_eq\",\"hmac\",\"pbkdf2\",\"sha1\",\"getrandom\",\"zeroize\"],\"chrono\":[\"chrono/default\"],\"default\":[\"aes-crypto\",\"bzip2\",\"deflate64\",\"deflate\",\"lzma\",\"time\",\"zstd\",\"xz\"],\"deflate\":[\"flate2/rust_backend\",\"deflate-zopfli\",\"deflate-flate2\"],\"deflate-flate2\":[\"_deflate-any\"],\"deflate-miniz\":[\"deflate\",\"deflate-flate2\"],\"deflate-zlib\":[\"flate2/zlib\",\"deflate-flate2\"],\"deflate-zlib-ng\":[\"flate2/zlib-ng\",\"deflate-flate2\"],\"deflate-zopfli\":[\"zopfli\",\"_deflate-any\"],\"lzma\":[\"lzma-rs/stream\"],\"nt-time\":[\"dep:nt-time\"],\"unreserved\":[],\"xz\":[\"dep:xz2\"]}}", "zmij_1.0.19": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\",\"target\":\"cfg(not(miri))\"},{\"name\":\"no-panic\",\"optional\":true,\"req\":\"^0.1.36\"},{\"kind\":\"dev\",\"name\":\"num-bigint\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"num-integer\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"opt-level\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"ryu\",\"req\":\"^1\"}],\"features\":{}}", "zopfli_0.8.3": "{\"dependencies\":[{\"name\":\"bumpalo\",\"req\":\"^3.19.0\"},{\"default_features\":false,\"name\":\"crc32fast\",\"optional\":true,\"req\":\"^1.5.0\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.28\"},{\"kind\":\"dev\",\"name\":\"miniz_oxide\",\"req\":\"^0.8.9\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.7.0\"},{\"kind\":\"dev\",\"name\":\"proptest-derive\",\"req\":\"^0.6.0\"},{\"default_features\":false,\"name\":\"simd-adler32\",\"optional\":true,\"req\":\"^0.3.7\"}],\"features\":{\"default\":[\"gzip\",\"std\",\"zlib\"],\"gzip\":[\"dep:crc32fast\"],\"nightly\":[\"crc32fast?/nightly\"],\"std\":[\"crc32fast?/std\",\"dep:log\",\"simd-adler32?/std\"],\"zlib\":[\"dep:simd-adler32\"]}}", + "zstd-safe_5.0.2+zstd.1.5.2": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2.21\"},{\"default_features\":false,\"name\":\"zstd-sys\",\"req\":\"^2.0.1\"}],\"features\":{\"arrays\":[],\"bindgen\":[\"zstd-sys/bindgen\"],\"debug\":[\"zstd-sys/debug\"],\"default\":[\"legacy\",\"arrays\",\"zdict_builder\"],\"doc-cfg\":[],\"experimental\":[\"zstd-sys/experimental\"],\"legacy\":[\"zstd-sys/legacy\"],\"no_asm\":[\"zstd-sys/no_asm\"],\"pkg-config\":[\"zstd-sys/pkg-config\"],\"std\":[\"zstd-sys/std\"],\"thin\":[\"zstd-sys/thin\"],\"zdict_builder\":[\"zstd-sys/zdict_builder\"],\"zstdmt\":[\"zstd-sys/zstdmt\"]}}", "zstd-safe_7.2.4": "{\"dependencies\":[{\"default_features\":false,\"name\":\"zstd-sys\",\"req\":\"^2.0.15\"}],\"features\":{\"arrays\":[],\"bindgen\":[\"zstd-sys/bindgen\"],\"debug\":[\"zstd-sys/debug\"],\"default\":[\"legacy\",\"arrays\",\"zdict_builder\"],\"doc-cfg\":[],\"experimental\":[\"zstd-sys/experimental\"],\"fat-lto\":[\"zstd-sys/fat-lto\"],\"legacy\":[\"zstd-sys/legacy\"],\"no_asm\":[\"zstd-sys/no_asm\"],\"pkg-config\":[\"zstd-sys/pkg-config\"],\"seekable\":[\"zstd-sys/seekable\"],\"std\":[\"zstd-sys/std\"],\"thin\":[\"zstd-sys/thin\"],\"thin-lto\":[\"zstd-sys/thin-lto\"],\"zdict_builder\":[\"zstd-sys/zdict_builder\"],\"zstdmt\":[\"zstd-sys/zstdmt\"]}}", "zstd-sys_2.0.16+zstd.1.5.7": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"runtime\"],\"kind\":\"build\",\"name\":\"bindgen\",\"optional\":true,\"req\":\"^0.72\"},{\"features\":[\"parallel\"],\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.45\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.28\"}],\"features\":{\"debug\":[],\"default\":[\"legacy\",\"zdict_builder\",\"bindgen\"],\"experimental\":[],\"fat-lto\":[],\"legacy\":[],\"no_asm\":[],\"no_wasm_shim\":[],\"non-cargo\":[],\"pkg-config\":[],\"seekable\":[],\"std\":[],\"thin\":[],\"thin-lto\":[],\"zdict_builder\":[],\"zstdmt\":[]}}", + "zstd_0.11.2+zstd.1.5.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^3.0\"},{\"kind\":\"dev\",\"name\":\"humansize\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"partial-io\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"zstd-safe\",\"req\":\"^5.0.1\"}],\"features\":{\"arrays\":[\"zstd-safe/arrays\"],\"bindgen\":[\"zstd-safe/bindgen\"],\"debug\":[\"zstd-safe/debug\"],\"default\":[\"legacy\",\"arrays\",\"zdict_builder\"],\"doc-cfg\":[],\"experimental\":[\"zstd-safe/experimental\"],\"legacy\":[\"zstd-safe/legacy\"],\"no_asm\":[\"zstd-safe/no_asm\"],\"pkg-config\":[\"zstd-safe/pkg-config\"],\"thin\":[\"zstd-safe/thin\"],\"wasm\":[],\"zdict_builder\":[\"zstd-safe/zdict_builder\"],\"zstdmt\":[\"zstd-safe/zstdmt\"]}}", "zstd_0.13.3": "{\"dependencies\":[{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4.0\"},{\"kind\":\"dev\",\"name\":\"humansize\",\"req\":\"^2.0\"},{\"kind\":\"dev\",\"name\":\"partial-io\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"zstd-safe\",\"req\":\"^7.1.0\"}],\"features\":{\"arrays\":[\"zstd-safe/arrays\"],\"bindgen\":[\"zstd-safe/bindgen\"],\"debug\":[\"zstd-safe/debug\"],\"default\":[\"legacy\",\"arrays\",\"zdict_builder\"],\"doc-cfg\":[],\"experimental\":[\"zstd-safe/experimental\"],\"fat-lto\":[\"zstd-safe/fat-lto\"],\"legacy\":[\"zstd-safe/legacy\"],\"no_asm\":[\"zstd-safe/no_asm\"],\"pkg-config\":[\"zstd-safe/pkg-config\"],\"thin\":[\"zstd-safe/thin\"],\"thin-lto\":[\"zstd-safe/thin-lto\"],\"wasm\":[],\"zdict_builder\":[\"zstd-safe/zdict_builder\"],\"zstdmt\":[\"zstd-safe/zstdmt\"]}}", "zune-core_0.4.12": "{\"dependencies\":[{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.17\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.52\"}],\"features\":{\"std\":[]}}", "zune-core_0.5.1": "{\"dependencies\":[{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"std\":[]}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 2ab1d5943c..62d2ffae3b 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -831,9 +831,9 @@ dependencies = [ "form_urlencoded", "futures-util", "http 1.4.0", - "http-body", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.8.1", "hyper-util", "itoa", "matchit 0.8.4", @@ -845,7 +845,7 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tower", "tower-layer", @@ -862,11 +862,11 @@ dependencies = [ "bytes", "futures-core", "http 1.4.0", - "http-body", + "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", - "sync_wrapper", + "sync_wrapper 1.0.2", "tower-layer", "tower-service", "tracing", @@ -1096,6 +1096,16 @@ dependencies = [ "bytes", ] +[[package]] +name = "bzip2" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" +dependencies = [ + "bzip2-sys", + "libc", +] + [[package]] name = "bzip2" version = "0.5.2" @@ -1393,7 +1403,7 @@ dependencies = [ "http 1.4.0", "pretty_assertions", "regex-lite", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "thiserror 2.0.18", @@ -1428,6 +1438,7 @@ dependencies = [ "codex-feedback", "codex-file-search", "codex-login", + "codex-otel", "codex-protocol", "codex-rmcp-client", "codex-shell-command", @@ -1473,6 +1484,7 @@ dependencies = [ "schemars 0.8.22", "serde", "serde_json", + "serde_with", "shlex", "similar", "strum_macros 0.27.2", @@ -1528,6 +1540,39 @@ dependencies = [ "tokio", ] +[[package]] +name = "codex-artifact-presentation" +version = "0.0.0" +dependencies = [ + "base64 0.22.1", + "image", + "ppt-rs", + "pretty_assertions", + "reqwest 0.12.28", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "tiny_http", + "uuid", + "zip 2.4.2", +] + +[[package]] +name = "codex-artifact-spreadsheet" +version = "0.0.0" +dependencies = [ + "base64 0.22.1", + "pretty_assertions", + "regex", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "uuid", + "zip 2.4.2", +] + [[package]] name = "codex-async-utils" version = "0.0.0" @@ -1547,7 +1592,7 @@ dependencies = [ "codex-core", "codex-protocol", "pretty_assertions", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", ] @@ -1635,7 +1680,7 @@ dependencies = [ "opentelemetry", "opentelemetry_sdk", "rand 0.9.2", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "thiserror 2.0.18", @@ -1643,7 +1688,7 @@ dependencies = [ "tracing", "tracing-opentelemetry", "tracing-subscriber", - "zstd", + "zstd 0.13.3", ] [[package]] @@ -1687,7 +1732,7 @@ dependencies = [ "owo-colors", "pretty_assertions", "ratatui", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "supports-color 3.0.2", @@ -1756,6 +1801,8 @@ dependencies = [ "codex-app-server-protocol", "codex-apply-patch", "codex-arg0", + "codex-artifact-presentation", + "codex-artifact-spreadsheet", "codex-async-utils", "codex-client", "codex-config", @@ -1810,7 +1857,7 @@ dependencies = [ "pretty_assertions", "rand 0.9.2", "regex-lite", - "reqwest", + "reqwest 0.12.28", "rmcp", "schemars 0.8.22", "seccompiler", @@ -1842,8 +1889,8 @@ dependencies = [ "wildmatch", "windows-sys 0.52.0", "wiremock", - "zip", - "zstd", + "zip 2.4.2", + "zstd 0.13.3", ] [[package]] @@ -2035,7 +2082,7 @@ name = "codex-lmstudio" version = "0.0.0" dependencies = [ "codex-core", - "reqwest", + "reqwest 0.12.28", "serde_json", "tokio", "tracing", @@ -2054,7 +2101,7 @@ dependencies = [ "codex-core", "core_test_support", "rand 0.9.2", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "sha2", @@ -2135,7 +2182,7 @@ dependencies = [ "codex-core", "futures", "pretty_assertions", - "reqwest", + "reqwest 0.12.28", "semver", "serde_json", "tokio", @@ -2162,7 +2209,7 @@ dependencies = [ "opentelemetry_sdk", "os_info", "pretty_assertions", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "strum_macros 0.27.2", @@ -2218,7 +2265,7 @@ dependencies = [ "codex-process-hardening", "ctor 0.6.3", "libc", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "tiny_http", @@ -2240,7 +2287,7 @@ dependencies = [ "keyring", "oauth2", "pretty_assertions", - "reqwest", + "reqwest 0.12.28", "rmcp", "schemars 0.8.22", "serde", @@ -2426,7 +2473,7 @@ dependencies = [ "ratatui", "ratatui-macros", "regex-lite", - "reqwest", + "reqwest 0.12.28", "rmcp", "serde", "serde_json", @@ -2768,6 +2815,12 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "constant_time_eq" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" + [[package]] name = "constant_time_eq" version = "0.3.1" @@ -2843,7 +2896,7 @@ dependencies = [ "notify", "pretty_assertions", "regex-lite", - "reqwest", + "reqwest 0.12.28", "serde_json", "shlex", "tempfile", @@ -2851,7 +2904,7 @@ dependencies = [ "tokio-tungstenite", "walkdir", "wiremock", - "zstd", + "zstd 0.13.3", ] [[package]] @@ -3020,6 +3073,29 @@ dependencies = [ "typenum", ] +[[package]] +name = "cssparser" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3df4f93e5fbbe73ec01ec8d3f68bba73107993a5b1e7519273c32db9b0d5be" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.11.3", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.114", +] + [[package]] name = "csv" version = "1.4.0" @@ -3340,6 +3416,17 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "derive_more" version = "1.0.0" @@ -3509,6 +3596,21 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + [[package]] name = "dtor" version = "0.1.1" @@ -3556,6 +3658,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ego-tree" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12a0bb14ac04a9fcf170d0bbbef949b44cc492f4452bd20c095636956f653642" + [[package]] name = "either" version = "1.15.0" @@ -4019,6 +4127,16 @@ dependencies = [ "libc", ] +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + [[package]] name = "futures" version = "0.3.31" @@ -4247,6 +4365,25 @@ dependencies = [ "regex-syntax 0.8.8", ] +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap 2.13.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "h2" version = "0.4.13" @@ -4456,6 +4593,20 @@ version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" +[[package]] +name = "html5ever" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bea68cab48b8459f17cf1c944c67ddc572d272d9f2b274140f223ecb1da4a3b7" +dependencies = [ + "log", + "mac", + "markup5ever", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "http" version = "0.2.12" @@ -4477,6 +4628,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -4496,7 +4658,7 @@ dependencies = [ "bytes", "futures-core", "http 1.4.0", - "http-body", + "http-body 1.0.1", "pin-project-lite", ] @@ -4518,6 +4680,30 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + [[package]] name = "hyper" version = "1.8.1" @@ -4528,9 +4714,9 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", + "h2 0.4.13", "http 1.4.0", - "http-body", + "http-body 1.0.1", "httparse", "httpdate", "itoa", @@ -4548,7 +4734,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ "http 1.4.0", - "hyper", + "hyper 1.8.1", "hyper-util", "rustls", "rustls-native-certs", @@ -4565,13 +4751,26 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" dependencies = [ - "hyper", + "hyper 1.8.1", "hyper-util", "pin-project-lite", "tokio", "tower-service", ] +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper 0.14.32", + "native-tls", + "tokio", + "tokio-native-tls", +] + [[package]] name = "hyper-tls" version = "0.6.0" @@ -4580,7 +4779,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper", + "hyper 1.8.1", "hyper-util", "native-tls", "tokio", @@ -4600,14 +4799,14 @@ dependencies = [ "futures-core", "futures-util", "http 1.4.0", - "http-body", - "hyper", + "http-body 1.0.1", + "hyper 1.8.1", "ipnet", "libc", "percent-encoding", "pin-project-lite", "socket2 0.6.2", - "system-configuration", + "system-configuration 0.6.1", "tokio", "tower-service", "tracing", @@ -5515,6 +5714,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + [[package]] name = "mach2" version = "0.4.3" @@ -5530,6 +5735,20 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" +[[package]] +name = "markup5ever" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2629bb1404f3d34c2e921f21fd34ba00b206124c81f65c50b43b6aaefeb016" +dependencies = [ + "log", + "phf 0.10.1", + "phf_codegen", + "string_cache", + "string_cache_codegen", + "tendril", +] + [[package]] name = "matchers" version = "0.2.0" @@ -6027,7 +6246,7 @@ dependencies = [ "getrandom 0.2.17", "http 1.4.0", "rand 0.8.5", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "serde_path_to_error", @@ -6388,7 +6607,7 @@ dependencies = [ "bytes", "http 1.4.0", "opentelemetry", - "reqwest", + "reqwest 0.12.28", ] [[package]] @@ -6403,7 +6622,7 @@ dependencies = [ "opentelemetry-proto", "opentelemetry_sdk", "prost", - "reqwest", + "reqwest 0.12.28", "serde_json", "thiserror 2.0.18", "tokio", @@ -6532,6 +6751,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "paste" version = "1.0.15" @@ -6568,6 +6798,18 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pbkdf2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +dependencies = [ + "digest", + "hmac", + "password-hash", + "sha2", +] + [[package]] name = "pbkdf2" version = "0.12.2" @@ -6624,13 +6866,84 @@ dependencies = [ "indexmap 2.13.0", ] +[[package]] +name = "phf" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +dependencies = [ + "phf_shared 0.10.0", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_codegen" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb1c3a8bc4dd4e5cfce29b44ffc14bedd2ee294559a294e2a4d4c9e9a6a13cd" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", +] + +[[package]] +name = "phf_generator" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +dependencies = [ + "phf_shared 0.10.0", + "rand 0.8.5", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher 0.3.11", +] + [[package]] name = "phf_shared" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" dependencies = [ - "siphasher", + "siphasher 1.0.2", ] [[package]] @@ -6807,6 +7120,26 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppt-rs" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2135454fb8669ee8eef79c87a7e9ad6f5d563d13fb1041746e2d3e3e4d716749" +dependencies = [ + "chrono", + "clap", + "pulldown-cmark", + "regex", + "reqwest 0.11.27", + "scraper", + "syntect", + "thiserror 1.0.69", + "url", + "uuid", + "xml-rs", + "zip 0.6.6", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -7187,7 +7520,7 @@ version = "0.3.0-alpha.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3ff6a3c8ae690be8167e43777ba0bf6b0c8c2f6de165c538666affe2a32fd81" dependencies = [ - "h2", + "h2 0.4.13", "pin-project-lite", "rama-core", "rama-http", @@ -7259,7 +7592,7 @@ dependencies = [ "const_format", "fnv", "http 1.4.0", - "http-body", + "http-body 1.0.1", "http-body-util", "itoa", "memchr", @@ -7274,7 +7607,7 @@ dependencies = [ "rand 0.9.2", "serde", "serde_json", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", ] @@ -7644,6 +7977,46 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-tls 0.5.0", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 0.1.2", + "system-configuration 0.5.1", + "tokio", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg 0.50.0", +] + [[package]] name = "reqwest" version = "0.12.28" @@ -7656,13 +8029,13 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "h2", + "h2 0.4.13", "http 1.4.0", - "http-body", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.8.1", "hyper-rustls", - "hyper-tls", + "hyper-tls 0.6.0", "hyper-util", "js-sys", "log", @@ -7678,7 +8051,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tokio-native-tls", "tokio-rustls", @@ -7727,14 +8100,14 @@ dependencies = [ "chrono", "futures", "http 1.4.0", - "http-body", + "http-body 1.0.1", "http-body-util", "oauth2", "pastey", "pin-project-lite", "process-wrap", "rand 0.9.2", - "reqwest", + "reqwest 0.12.28", "rmcp-macros", "schemars 1.2.1", "serde", @@ -7922,6 +8295,15 @@ dependencies = [ "security-framework 3.5.1", ] +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -8130,13 +8512,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scraper" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585480e3719b311b78a573db1c9d9c4c1f8010c2dee4cc59c2efe58ea4dbc3e1" +dependencies = [ + "ahash", + "cssparser", + "ego-tree", + "getopts", + "html5ever", + "once_cell", + "selectors", + "tendril", +] + [[package]] name = "scrypt" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" dependencies = [ - "pbkdf2", + "pbkdf2 0.12.2", "salsa20", "sha2", ] @@ -8220,6 +8618,25 @@ dependencies = [ "libc", ] +[[package]] +name = "selectors" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4eb30575f3638fc8f6815f448d50cb1a2e255b0897985c8c59f4d37b72a07b06" +dependencies = [ + "bitflags 2.10.0", + "cssparser", + "derive_more 0.99.20", + "fxhash", + "log", + "new_debug_unreachable", + "phf 0.10.1", + "phf_codegen", + "precomputed-hash", + "servo_arc", + "smallvec", +] + [[package]] name = "self_cell" version = "0.10.3" @@ -8249,7 +8666,7 @@ checksum = "2f925d575b468e88b079faf590a8dd0c9c99e2ec29e9bab663ceb8b45056312f" dependencies = [ "httpdate", "native-tls", - "reqwest", + "reqwest 0.12.28", "sentry-actix", "sentry-backtrace", "sentry-contexts", @@ -8554,6 +8971,15 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "servo_arc" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d036d71a959e00c77a63538b90a6c2390969f9772b096ea837205c6bd0491a44" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "sha1" version = "0.10.6" @@ -8672,6 +9098,12 @@ version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + [[package]] name = "siphasher" version = "1.0.2" @@ -8958,7 +9390,7 @@ checksum = "eb4dc4d33c68ec1f27d386b5610a351922656e1fdf5c05bbaad930cd1519479a" dependencies = [ "bytes", "futures-util", - "http-body", + "http-body 1.0.1", "http-body-util", "pin-project-lite", ] @@ -9088,8 +9520,21 @@ checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" dependencies = [ "new_debug_unreachable", "parking_lot", - "phf_shared", + "phf_shared 0.11.3", "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", ] [[package]] @@ -9202,6 +9647,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -9252,6 +9703,17 @@ dependencies = [ "libc", ] +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "system-configuration-sys 0.5.0", +] + [[package]] name = "system-configuration" version = "0.6.1" @@ -9260,7 +9722,17 @@ checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ "bitflags 2.10.0", "core-foundation 0.9.4", - "system-configuration-sys", + "system-configuration-sys 0.6.0", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", ] [[package]] @@ -9292,6 +9764,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + [[package]] name = "term" version = "0.7.0" @@ -9743,15 +10226,15 @@ dependencies = [ "base64 0.22.1", "bytes", "http 1.4.0", - "http-body", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.8.1", "hyper-timeout", "hyper-util", "percent-encoding", "pin-project", "rustls-native-certs", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tokio-rustls", "tokio-stream", @@ -9783,7 +10266,7 @@ dependencies = [ "indexmap 2.13.0", "pin-project-lite", "slab", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tokio-util", "tower-layer", @@ -9801,7 +10284,7 @@ dependencies = [ "bytes", "futures-util", "http 1.4.0", - "http-body", + "http-body 1.0.1", "iri-string", "pin-project-lite", "tower", @@ -11242,7 +11725,7 @@ dependencies = [ "futures", "http 1.4.0", "http-body-util", - "hyper", + "hyper 1.8.1", "hyper-util", "log", "once_cell", @@ -11341,6 +11824,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + [[package]] name = "xz2" version = "0.1.7" @@ -11554,6 +12043,26 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "zip" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" +dependencies = [ + "aes", + "byteorder", + "bzip2 0.4.4", + "constant_time_eq 0.1.5", + "crc32fast", + "crossbeam-utils", + "flate2", + "hmac", + "pbkdf2 0.11.0", + "sha1", + "time", + "zstd 0.11.2+zstd.1.5.2", +] + [[package]] name = "zip" version = "2.4.2" @@ -11562,8 +12071,8 @@ checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" dependencies = [ "aes", "arbitrary", - "bzip2", - "constant_time_eq", + "bzip2 0.5.2", + "constant_time_eq 0.3.1", "crc32fast", "crossbeam-utils", "deflate64", @@ -11574,14 +12083,14 @@ dependencies = [ "indexmap 2.13.0", "lzma-rs", "memchr", - "pbkdf2", + "pbkdf2 0.12.2", "sha1", "thiserror 2.0.18", "time", "xz2", "zeroize", "zopfli", - "zstd", + "zstd 0.13.3", ] [[package]] @@ -11602,13 +12111,32 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "zstd" +version = "0.11.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" +dependencies = [ + "zstd-safe 5.0.2+zstd.1.5.2", +] + [[package]] name = "zstd" version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" dependencies = [ - "zstd-safe", + "zstd-safe 7.2.4", +] + +[[package]] +name = "zstd-safe" +version = "5.0.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db" +dependencies = [ + "libc", + "zstd-sys", ] [[package]] diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 01836cde05..9ee05e21b6 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -33,6 +33,8 @@ members = [ "mcp-server", "network-proxy", "ollama", + "artifact-presentation", + "artifact-spreadsheet", "process-hardening", "protocol", "rmcp-client", @@ -109,6 +111,8 @@ codex-mcp-server = { path = "mcp-server" } codex-network-proxy = { path = "network-proxy" } codex-ollama = { path = "ollama" } codex-otel = { path = "otel" } +codex-artifact-presentation = { path = "artifact-presentation" } +codex-artifact-spreadsheet = { path = "artifact-spreadsheet" } codex-process-hardening = { path = "process-hardening" } codex-protocol = { path = "protocol" } codex-responses-api-proxy = { path = "responses-api-proxy" } @@ -215,6 +219,7 @@ owo-colors = "4.3.0" path-absolutize = "3.1.1" pathdiff = "0.2" portable-pty = "0.9.0" +ppt-rs = "0.2.6" predicates = "3" pretty_assertions = "1.4.1" pulldown-cmark = "0.10" @@ -349,6 +354,7 @@ ignored = [ "openssl-sys", "codex-utils-readiness", "codex-secrets", + "codex-artifact-spreadsheet" ] [profile.release] diff --git a/codex-rs/app-server-protocol/Cargo.toml b/codex-rs/app-server-protocol/Cargo.toml index f5df7af034..dc6823a4af 100644 --- a/codex-rs/app-server-protocol/Cargo.toml +++ b/codex-rs/app-server-protocol/Cargo.toml @@ -20,6 +20,7 @@ codex-utils-absolute-path = { workspace = true } schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +serde_with = { workspace = true } shlex = { workspace = true } strum_macros = { workspace = true } thiserror = { workspace = true } diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 03e42bed45..01790fb98f 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1703,6 +1703,12 @@ } ] }, + "ServiceTier": { + "enum": [ + "fast" + ], + "type": "string" + }, "Settings": { "description": "Settings for a collaboration mode.", "properties": { @@ -1933,6 +1939,23 @@ } ] }, + "serviceTier": { + "anyOf": [ + { + "anyOf": [ + { + "$ref": "#/definitions/ServiceTier" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, "threadId": { "type": "string" } @@ -2155,6 +2178,23 @@ } ] }, + "serviceTier": { + "anyOf": [ + { + "anyOf": [ + { + "$ref": "#/definitions/ServiceTier" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, "threadId": { "type": "string" } @@ -2299,6 +2339,23 @@ "string", "null" ] + }, + "serviceTier": { + "anyOf": [ + { + "anyOf": [ + { + "$ref": "#/definitions/ServiceTier" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] } }, "type": "object" @@ -2409,6 +2466,24 @@ ], "description": "Override the sandbox policy for this turn and subsequent turns." }, + "serviceTier": { + "anyOf": [ + { + "anyOf": [ + { + "$ref": "#/definitions/ServiceTier" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ], + "description": "Override the service tier for this turn and subsequent turns." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/EventMsg.json b/codex-rs/app-server-protocol/schema/json/EventMsg.json index b3e4317065..4258bf7c2c 100644 --- a/codex-rs/app-server-protocol/schema/json/EventMsg.json +++ b/codex-rs/app-server-protocol/schema/json/EventMsg.json @@ -1138,6 +1138,16 @@ ], "description": "How to sandbox commands executed in the system" }, + "service_tier": { + "anyOf": [ + { + "$ref": "#/definitions/ServiceTier" + }, + { + "type": "null" + } + ] + }, "session_id": { "$ref": "#/definitions/ThreadId" }, @@ -4234,8 +4244,14 @@ { "additionalProperties": false, "properties": { - "SessionCreated": { + "SessionUpdated": { "properties": { + "instructions": { + "type": [ + "string", + "null" + ] + }, "session_id": { "type": "string" } @@ -4246,27 +4262,6 @@ "type": "object" } }, - "required": [ - "SessionCreated" - ], - "title": "SessionCreatedRealtimeEvent", - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "SessionUpdated": { - "properties": { - "backend_prompt": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - } - }, "required": [ "SessionUpdated" ], @@ -4297,6 +4292,40 @@ "title": "ConversationItemAddedRealtimeEvent", "type": "object" }, + { + "additionalProperties": false, + "properties": { + "ConversationItemDone": { + "properties": { + "item_id": { + "type": "string" + } + }, + "required": [ + "item_id" + ], + "type": "object" + } + }, + "required": [ + "ConversationItemDone" + ], + "title": "ConversationItemDoneRealtimeEvent", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "HandoffRequested": { + "$ref": "#/definitions/RealtimeHandoffRequested" + } + }, + "required": [ + "HandoffRequested" + ], + "title": "HandoffRequestedRealtimeEvent", + "type": "object" + }, { "additionalProperties": false, "properties": { @@ -4312,6 +4341,47 @@ } ] }, + "RealtimeHandoffMessage": { + "properties": { + "role": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "role", + "text" + ], + "type": "object" + }, + "RealtimeHandoffRequested": { + "properties": { + "handoff_id": { + "type": "string" + }, + "input_transcript": { + "type": "string" + }, + "item_id": { + "type": "string" + }, + "messages": { + "items": { + "$ref": "#/definitions/RealtimeHandoffMessage" + }, + "type": "array" + } + }, + "required": [ + "handoff_id", + "input_transcript", + "item_id", + "messages" + ], + "type": "object" + }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -5350,6 +5420,12 @@ } ] }, + "ServiceTier": { + "enum": [ + "fast" + ], + "type": "string" + }, "SessionNetworkProxyRuntime": { "properties": { "admin_addr": { @@ -6694,6 +6770,16 @@ ], "description": "How to sandbox commands executed in the system" }, + "service_tier": { + "anyOf": [ + { + "$ref": "#/definitions/ServiceTier" + }, + { + "type": "null" + } + ] + }, "session_id": { "$ref": "#/definitions/ThreadId" }, diff --git a/codex-rs/app-server-protocol/schema/json/JSONRPCMessage.json b/codex-rs/app-server-protocol/schema/json/JSONRPCMessage.json index b2f6c31011..27b78b90c9 100644 --- a/codex-rs/app-server-protocol/schema/json/JSONRPCMessage.json +++ b/codex-rs/app-server-protocol/schema/json/JSONRPCMessage.json @@ -70,7 +70,18 @@ "method": { "type": "string" }, - "params": true + "params": true, + "trace": { + "anyOf": [ + { + "$ref": "#/definitions/W3cTraceContext" + }, + { + "type": "null" + } + ], + "description": "Optional W3C Trace Context for distributed tracing." + } }, "required": [ "id", @@ -102,6 +113,23 @@ "type": "integer" } ] + }, + "W3cTraceContext": { + "properties": { + "traceparent": { + "type": [ + "string", + "null" + ] + }, + "tracestate": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" } }, "description": "Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent.", diff --git a/codex-rs/app-server-protocol/schema/json/JSONRPCRequest.json b/codex-rs/app-server-protocol/schema/json/JSONRPCRequest.json index cc7a16f1ba..e4ea7c206b 100644 --- a/codex-rs/app-server-protocol/schema/json/JSONRPCRequest.json +++ b/codex-rs/app-server-protocol/schema/json/JSONRPCRequest.json @@ -11,6 +11,23 @@ "type": "integer" } ] + }, + "W3cTraceContext": { + "properties": { + "traceparent": { + "type": [ + "string", + "null" + ] + }, + "tracestate": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" } }, "description": "A request that expects a response.", @@ -21,7 +38,18 @@ "method": { "type": "string" }, - "params": true + "params": true, + "trace": { + "anyOf": [ + { + "$ref": "#/definitions/W3cTraceContext" + }, + { + "type": "null" + } + ], + "description": "Optional W3C Trace Context for distributed tracing." + } }, "required": [ "id", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index edee815e08..ddeee0ce73 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -5,7 +5,7 @@ "properties": { "read": { "items": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/v2/AbsolutePathBuf" }, "type": [ "array", @@ -14,7 +14,7 @@ }, "write": { "items": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/v2/AbsolutePathBuf" }, "type": [ "array", @@ -1382,7 +1382,7 @@ "thread_id": { "allOf": [ { - "$ref": "#/definitions/ThreadId" + "$ref": "#/definitions/v2/ThreadId" } ], "description": "Thread ID of the receiver/new agent." @@ -1420,7 +1420,7 @@ "thread_id": { "allOf": [ { - "$ref": "#/definitions/ThreadId" + "$ref": "#/definitions/v2/ThreadId" } ], "description": "Thread ID of the receiver/new agent." @@ -2306,6 +2306,16 @@ ], "description": "How to sandbox commands executed in the system" }, + "service_tier": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ServiceTier" + }, + { + "type": "null" + } + ] + }, "session_id": { "$ref": "#/definitions/v2/ThreadId" }, @@ -4722,7 +4732,7 @@ "properties": { "read": { "items": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/v2/AbsolutePathBuf" }, "type": [ "array", @@ -4731,7 +4741,7 @@ }, "write": { "items": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/v2/AbsolutePathBuf" }, "type": [ "array", @@ -5016,7 +5026,18 @@ "method": { "type": "string" }, - "params": true + "params": true, + "trace": { + "anyOf": [ + { + "$ref": "#/definitions/W3cTraceContext" + }, + { + "type": "null" + } + ], + "description": "Optional W3C Trace Context for distributed tracing." + } }, "required": [ "id", @@ -5443,8 +5464,14 @@ { "additionalProperties": false, "properties": { - "SessionCreated": { + "SessionUpdated": { "properties": { + "instructions": { + "type": [ + "string", + "null" + ] + }, "session_id": { "type": "string" } @@ -5455,27 +5482,6 @@ "type": "object" } }, - "required": [ - "SessionCreated" - ], - "title": "SessionCreatedRealtimeEvent", - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "SessionUpdated": { - "properties": { - "backend_prompt": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - } - }, "required": [ "SessionUpdated" ], @@ -5506,6 +5512,40 @@ "title": "ConversationItemAddedRealtimeEvent", "type": "object" }, + { + "additionalProperties": false, + "properties": { + "ConversationItemDone": { + "properties": { + "item_id": { + "type": "string" + } + }, + "required": [ + "item_id" + ], + "type": "object" + } + }, + "required": [ + "ConversationItemDone" + ], + "title": "ConversationItemDoneRealtimeEvent", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "HandoffRequested": { + "$ref": "#/definitions/RealtimeHandoffRequested" + } + }, + "required": [ + "HandoffRequested" + ], + "title": "HandoffRequestedRealtimeEvent", + "type": "object" + }, { "additionalProperties": false, "properties": { @@ -5521,6 +5561,47 @@ } ] }, + "RealtimeHandoffMessage": { + "properties": { + "role": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "role", + "text" + ], + "type": "object" + }, + "RealtimeHandoffRequested": { + "properties": { + "handoff_id": { + "type": "string" + }, + "input_transcript": { + "type": "string" + }, + "item_id": { + "type": "string" + }, + "messages": { + "items": { + "$ref": "#/definitions/RealtimeHandoffMessage" + }, + "type": "array" + } + }, + "required": [ + "handoff_id", + "input_transcript", + "item_id", + "messages" + ], + "type": "object" + }, "RejectConfig": { "properties": { "mcp_elicitations": { @@ -7050,7 +7131,7 @@ "properties": { "content": { "items": { - "$ref": "#/definitions/UserInput" + "$ref": "#/definitions/v2/UserInput" }, "type": "array" }, @@ -7088,7 +7169,7 @@ "phase": { "anyOf": [ { - "$ref": "#/definitions/MessagePhase" + "$ref": "#/definitions/v2/MessagePhase" }, { "type": "null" @@ -7173,7 +7254,7 @@ { "properties": { "action": { - "$ref": "#/definitions/WebSearchAction" + "$ref": "#/definitions/v2/WebSearchAction" }, "id": { "type": "string" @@ -7220,6 +7301,23 @@ } ] }, + "W3cTraceContext": { + "properties": { + "traceparent": { + "type": [ + "string", + "null" + ] + }, + "tracestate": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, "v2": { "AbsolutePathBuf": { "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", @@ -8507,6 +8605,16 @@ } ] }, + "service_tier": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ServiceTier" + }, + { + "type": "null" + } + ] + }, "tools": { "anyOf": [ { @@ -10757,6 +10865,16 @@ } ] }, + "service_tier": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ServiceTier" + }, + { + "type": "null" + } + ] + }, "web_search": { "anyOf": [ { @@ -11908,6 +12026,12 @@ "title": "ServerRequestResolvedNotification", "type": "object" }, + "ServiceTier": { + "enum": [ + "fast" + ], + "type": "string" + }, "SessionSource": { "oneOf": [ { @@ -12723,6 +12847,23 @@ } ] }, + "serviceTier": { + "anyOf": [ + { + "anyOf": [ + { + "$ref": "#/definitions/v2/ServiceTier" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, "threadId": { "type": "string" } @@ -12761,6 +12902,16 @@ "sandbox": { "$ref": "#/definitions/v2/SandboxPolicy" }, + "serviceTier": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ServiceTier" + }, + { + "type": "null" + } + ] + }, "thread": { "$ref": "#/definitions/v2/Thread" } @@ -13706,6 +13857,23 @@ } ] }, + "serviceTier": { + "anyOf": [ + { + "anyOf": [ + { + "$ref": "#/definitions/v2/ServiceTier" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, "threadId": { "type": "string" } @@ -13744,6 +13912,16 @@ "sandbox": { "$ref": "#/definitions/v2/SandboxPolicy" }, + "serviceTier": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ServiceTier" + }, + { + "type": "null" + } + ] + }, "thread": { "$ref": "#/definitions/v2/Thread" } @@ -13922,6 +14100,23 @@ "string", "null" ] + }, + "serviceTier": { + "anyOf": [ + { + "anyOf": [ + { + "$ref": "#/definitions/v2/ServiceTier" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] } }, "title": "ThreadStartParams", @@ -13955,6 +14150,16 @@ "sandbox": { "$ref": "#/definitions/v2/SandboxPolicy" }, + "serviceTier": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ServiceTier" + }, + { + "type": "null" + } + ] + }, "thread": { "$ref": "#/definitions/v2/Thread" } @@ -14522,6 +14727,24 @@ ], "description": "Override the sandbox policy for this turn and subsequent turns." }, + "serviceTier": { + "anyOf": [ + { + "anyOf": [ + { + "$ref": "#/definitions/v2/ServiceTier" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ], + "description": "Override the service tier for this turn and subsequent turns." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json new file mode 100644 index 0000000000..0f8e83d572 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -0,0 +1,13958 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "Account": { + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "apiKey" + ], + "title": "ApiKeyAccountType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ApiKeyAccount", + "type": "object" + }, + { + "properties": { + "email": { + "type": "string" + }, + "planType": { + "$ref": "#/definitions/PlanType" + }, + "type": { + "enum": [ + "chatgpt" + ], + "title": "ChatgptAccountType", + "type": "string" + } + }, + "required": [ + "email", + "planType", + "type" + ], + "title": "ChatgptAccount", + "type": "object" + } + ] + }, + "AccountLoginCompletedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "error": { + "type": [ + "string", + "null" + ] + }, + "loginId": { + "type": [ + "string", + "null" + ] + }, + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ], + "title": "AccountLoginCompletedNotification", + "type": "object" + }, + "AccountRateLimitsUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "rateLimits": { + "$ref": "#/definitions/RateLimitSnapshot" + } + }, + "required": [ + "rateLimits" + ], + "title": "AccountRateLimitsUpdatedNotification", + "type": "object" + }, + "AccountUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "authMode": { + "anyOf": [ + { + "$ref": "#/definitions/AuthMode" + }, + { + "type": "null" + } + ] + }, + "planType": { + "anyOf": [ + { + "$ref": "#/definitions/PlanType" + }, + { + "type": "null" + } + ] + } + }, + "title": "AccountUpdatedNotification", + "type": "object" + }, + "AgentMessageContent": { + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "Text" + ], + "title": "TextAgentMessageContentType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "TextAgentMessageContent", + "type": "object" + } + ] + }, + "AgentMessageDeltaNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "delta": { + "type": "string" + }, + "itemId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "delta", + "itemId", + "threadId", + "turnId" + ], + "title": "AgentMessageDeltaNotification", + "type": "object" + }, + "AgentStatus": { + "description": "Agent lifecycle status, derived from emitted events.", + "oneOf": [ + { + "description": "Agent is waiting for initialization.", + "enum": [ + "pending_init" + ], + "type": "string" + }, + { + "description": "Agent is currently running.", + "enum": [ + "running" + ], + "type": "string" + }, + { + "additionalProperties": false, + "description": "Agent is done. Contains the final assistant message.", + "properties": { + "completed": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "completed" + ], + "title": "CompletedAgentStatus", + "type": "object" + }, + { + "additionalProperties": false, + "description": "Agent encountered an error.", + "properties": { + "errored": { + "type": "string" + } + }, + "required": [ + "errored" + ], + "title": "ErroredAgentStatus", + "type": "object" + }, + { + "description": "Agent has been shutdown.", + "enum": [ + "shutdown" + ], + "type": "string" + }, + { + "description": "Agent is not found.", + "enum": [ + "not_found" + ], + "type": "string" + } + ] + }, + "AnalyticsConfig": { + "additionalProperties": true, + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, + "AppBranding": { + "description": "EXPERIMENTAL - app metadata returned by app-list APIs.", + "properties": { + "category": { + "type": [ + "string", + "null" + ] + }, + "developer": { + "type": [ + "string", + "null" + ] + }, + "isDiscoverableApp": { + "type": "boolean" + }, + "privacyPolicy": { + "type": [ + "string", + "null" + ] + }, + "termsOfService": { + "type": [ + "string", + "null" + ] + }, + "website": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "isDiscoverableApp" + ], + "type": "object" + }, + "AppConfig": { + "properties": { + "default_tools_approval_mode": { + "anyOf": [ + { + "$ref": "#/definitions/AppToolApproval" + }, + { + "type": "null" + } + ] + }, + "default_tools_enabled": { + "type": [ + "boolean", + "null" + ] + }, + "destructive_enabled": { + "type": [ + "boolean", + "null" + ] + }, + "enabled": { + "default": true, + "type": "boolean" + }, + "open_world_enabled": { + "type": [ + "boolean", + "null" + ] + }, + "tools": { + "anyOf": [ + { + "$ref": "#/definitions/AppToolsConfig" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "AppInfo": { + "description": "EXPERIMENTAL - app metadata returned by app-list APIs.", + "properties": { + "appMetadata": { + "anyOf": [ + { + "$ref": "#/definitions/AppMetadata" + }, + { + "type": "null" + } + ] + }, + "branding": { + "anyOf": [ + { + "$ref": "#/definitions/AppBranding" + }, + { + "type": "null" + } + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "distributionChannel": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "installUrl": { + "type": [ + "string", + "null" + ] + }, + "isAccessible": { + "default": false, + "type": "boolean" + }, + "isEnabled": { + "default": true, + "description": "Whether this app is enabled in config.toml. Example: ```toml [apps.bad_app] enabled = false ```", + "type": "boolean" + }, + "labels": { + "additionalProperties": { + "type": "string" + }, + "type": [ + "object", + "null" + ] + }, + "logoUrl": { + "type": [ + "string", + "null" + ] + }, + "logoUrlDark": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "type": "object" + }, + "AppListUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - notification emitted when the app list changes.", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/AppInfo" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "title": "AppListUpdatedNotification", + "type": "object" + }, + "AppMetadata": { + "properties": { + "categories": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "developer": { + "type": [ + "string", + "null" + ] + }, + "firstPartyRequiresInstall": { + "type": [ + "boolean", + "null" + ] + }, + "firstPartyType": { + "type": [ + "string", + "null" + ] + }, + "review": { + "anyOf": [ + { + "$ref": "#/definitions/AppReview" + }, + { + "type": "null" + } + ] + }, + "screenshots": { + "items": { + "$ref": "#/definitions/AppScreenshot" + }, + "type": [ + "array", + "null" + ] + }, + "seoDescription": { + "type": [ + "string", + "null" + ] + }, + "showInComposerWhenUnlinked": { + "type": [ + "boolean", + "null" + ] + }, + "subCategories": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "version": { + "type": [ + "string", + "null" + ] + }, + "versionId": { + "type": [ + "string", + "null" + ] + }, + "versionNotes": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "AppReview": { + "properties": { + "status": { + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "AppScreenshot": { + "properties": { + "fileId": { + "type": [ + "string", + "null" + ] + }, + "url": { + "type": [ + "string", + "null" + ] + }, + "userPrompt": { + "type": "string" + } + }, + "required": [ + "userPrompt" + ], + "type": "object" + }, + "AppToolApproval": { + "enum": [ + "auto", + "prompt", + "approve" + ], + "type": "string" + }, + "AppToolConfig": { + "properties": { + "approval_mode": { + "anyOf": [ + { + "$ref": "#/definitions/AppToolApproval" + }, + { + "type": "null" + } + ] + }, + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, + "AppToolsConfig": { + "type": "object" + }, + "AppsConfig": { + "properties": { + "_default": { + "anyOf": [ + { + "$ref": "#/definitions/AppsDefaultConfig" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "type": "object" + }, + "AppsDefaultConfig": { + "properties": { + "destructive_enabled": { + "default": true, + "type": "boolean" + }, + "enabled": { + "default": true, + "type": "boolean" + }, + "open_world_enabled": { + "default": true, + "type": "boolean" + } + }, + "type": "object" + }, + "AppsListParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - list available apps/connectors.", + "properties": { + "cursor": { + "description": "Opaque pagination cursor returned by a previous call.", + "type": [ + "string", + "null" + ] + }, + "forceRefetch": { + "description": "When true, bypass app caches and fetch the latest data from sources.", + "type": "boolean" + }, + "limit": { + "description": "Optional page size; defaults to a reasonable server-side value.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "threadId": { + "description": "Optional thread id used to evaluate app feature gating from that thread's config.", + "type": [ + "string", + "null" + ] + } + }, + "title": "AppsListParams", + "type": "object" + }, + "AppsListResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - app list response.", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/AppInfo" + }, + "type": "array" + }, + "nextCursor": { + "description": "Opaque cursor to pass to the next call to continue after the last item. If None, there are no more items to return.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "data" + ], + "title": "AppsListResponse", + "type": "object" + }, + "AskForApproval": { + "oneOf": [ + { + "enum": [ + "untrusted", + "on-failure", + "on-request", + "never" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "reject": { + "properties": { + "mcp_elicitations": { + "type": "boolean" + }, + "rules": { + "type": "boolean" + }, + "sandbox_approval": { + "type": "boolean" + } + }, + "required": [ + "mcp_elicitations", + "rules", + "sandbox_approval" + ], + "type": "object" + } + }, + "required": [ + "reject" + ], + "title": "RejectAskForApproval", + "type": "object" + } + ] + }, + "AuthMode": { + "description": "Authentication mode for OpenAI-backed providers.", + "oneOf": [ + { + "description": "OpenAI API key provided by the caller and stored by Codex.", + "enum": [ + "apikey" + ], + "type": "string" + }, + { + "description": "ChatGPT OAuth managed by Codex (tokens persisted and refreshed by Codex).", + "enum": [ + "chatgpt" + ], + "type": "string" + }, + { + "description": "[UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE.\n\nChatGPT auth tokens are supplied by an external host app and are only stored in memory. Token refresh must be handled by the external host app.", + "enum": [ + "chatgptAuthTokens" + ], + "type": "string" + } + ] + }, + "ByteRange": { + "properties": { + "end": { + "format": "uint", + "minimum": 0.0, + "type": "integer" + }, + "start": { + "format": "uint", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "end", + "start" + ], + "type": "object" + }, + "CallToolResult": { + "description": "The server's response to a tool call.", + "properties": { + "_meta": true, + "content": { + "items": true, + "type": "array" + }, + "isError": { + "type": [ + "boolean", + "null" + ] + }, + "structuredContent": true + }, + "required": [ + "content" + ], + "type": "object" + }, + "CancelLoginAccountParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "loginId": { + "type": "string" + } + }, + "required": [ + "loginId" + ], + "title": "CancelLoginAccountParams", + "type": "object" + }, + "CancelLoginAccountResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "status": { + "$ref": "#/definitions/CancelLoginAccountStatus" + } + }, + "required": [ + "status" + ], + "title": "CancelLoginAccountResponse", + "type": "object" + }, + "CancelLoginAccountStatus": { + "enum": [ + "canceled", + "notFound" + ], + "type": "string" + }, + "ClientInfo": { + "properties": { + "name": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "version": { + "type": "string" + } + }, + "required": [ + "name", + "version" + ], + "type": "object" + }, + "ClientRequest": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Request from the client to the server.", + "oneOf": [ + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "initialize" + ], + "title": "InitializeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/InitializeParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "InitializeRequest", + "type": "object" + }, + { + "description": "NEW APIs", + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/start" + ], + "title": "Thread/startRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadStartParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/startRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/resume" + ], + "title": "Thread/resumeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadResumeParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/resumeRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/fork" + ], + "title": "Thread/forkRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadForkParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/forkRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/archive" + ], + "title": "Thread/archiveRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadArchiveParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/archiveRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/unsubscribe" + ], + "title": "Thread/unsubscribeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadUnsubscribeParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/unsubscribeRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/name/set" + ], + "title": "Thread/name/setRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadSetNameParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/name/setRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/unarchive" + ], + "title": "Thread/unarchiveRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadUnarchiveParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/unarchiveRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/compact/start" + ], + "title": "Thread/compact/startRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadCompactStartParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/compact/startRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/rollback" + ], + "title": "Thread/rollbackRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadRollbackParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/rollbackRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/list" + ], + "title": "Thread/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/listRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/loaded/list" + ], + "title": "Thread/loaded/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadLoadedListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/loaded/listRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/read" + ], + "title": "Thread/readRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadReadParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/readRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "skills/list" + ], + "title": "Skills/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/SkillsListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Skills/listRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "skills/remote/list" + ], + "title": "Skills/remote/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/SkillsRemoteReadParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Skills/remote/listRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "skills/remote/export" + ], + "title": "Skills/remote/exportRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/SkillsRemoteWriteParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Skills/remote/exportRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "app/list" + ], + "title": "App/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/AppsListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "App/listRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "skills/config/write" + ], + "title": "Skills/config/writeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/SkillsConfigWriteParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Skills/config/writeRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "turn/start" + ], + "title": "Turn/startRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/TurnStartParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Turn/startRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "turn/steer" + ], + "title": "Turn/steerRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/TurnSteerParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Turn/steerRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "turn/interrupt" + ], + "title": "Turn/interruptRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/TurnInterruptParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Turn/interruptRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "review/start" + ], + "title": "Review/startRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ReviewStartParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Review/startRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "model/list" + ], + "title": "Model/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ModelListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Model/listRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "experimentalFeature/list" + ], + "title": "ExperimentalFeature/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ExperimentalFeatureListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "ExperimentalFeature/listRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "mcpServer/oauth/login" + ], + "title": "McpServer/oauth/loginRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/McpServerOauthLoginParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "McpServer/oauth/loginRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "config/mcpServer/reload" + ], + "title": "Config/mcpServer/reloadRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "Config/mcpServer/reloadRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "mcpServerStatus/list" + ], + "title": "McpServerStatus/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ListMcpServerStatusParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "McpServerStatus/listRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "windowsSandbox/setupStart" + ], + "title": "WindowsSandbox/setupStartRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/WindowsSandboxSetupStartParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "WindowsSandbox/setupStartRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "account/login/start" + ], + "title": "Account/login/startRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/LoginAccountParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Account/login/startRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "account/login/cancel" + ], + "title": "Account/login/cancelRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/CancelLoginAccountParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Account/login/cancelRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "account/logout" + ], + "title": "Account/logoutRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "Account/logoutRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "account/rateLimits/read" + ], + "title": "Account/rateLimits/readRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "Account/rateLimits/readRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "feedback/upload" + ], + "title": "Feedback/uploadRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FeedbackUploadParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Feedback/uploadRequest", + "type": "object" + }, + { + "description": "Execute a command (argv vector) under the server's sandbox.", + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "command/exec" + ], + "title": "Command/execRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/CommandExecParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Command/execRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "config/read" + ], + "title": "Config/readRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ConfigReadParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Config/readRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "externalAgentConfig/detect" + ], + "title": "ExternalAgentConfig/detectRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ExternalAgentConfigDetectParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "ExternalAgentConfig/detectRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "externalAgentConfig/import" + ], + "title": "ExternalAgentConfig/importRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ExternalAgentConfigImportParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "ExternalAgentConfig/importRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "config/value/write" + ], + "title": "Config/value/writeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ConfigValueWriteParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Config/value/writeRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "config/batchWrite" + ], + "title": "Config/batchWriteRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ConfigBatchWriteParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Config/batchWriteRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "configRequirements/read" + ], + "title": "ConfigRequirements/readRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "ConfigRequirements/readRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "account/read" + ], + "title": "Account/readRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/GetAccountParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Account/readRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "fuzzyFileSearch" + ], + "title": "FuzzyFileSearchRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FuzzyFileSearchParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "FuzzyFileSearchRequest", + "type": "object" + } + ], + "title": "ClientRequest" + }, + "CodexErrorInfo": { + "description": "This translation layer make sure that we expose codex error code in camel case.\n\nWhen an upstream HTTP status is available (for example, from the Responses API or a provider), it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant.", + "oneOf": [ + { + "enum": [ + "contextWindowExceeded", + "usageLimitExceeded", + "serverOverloaded", + "internalServerError", + "unauthorized", + "badRequest", + "threadRollbackFailed", + "sandboxError", + "other" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "httpConnectionFailed" + ], + "title": "HttpConnectionFailedCodexErrorInfo", + "type": "object" + }, + { + "additionalProperties": false, + "description": "Failed to connect to the response SSE stream.", + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamConnectionFailed" + ], + "title": "ResponseStreamConnectionFailedCodexErrorInfo", + "type": "object" + }, + { + "additionalProperties": false, + "description": "The response SSE stream disconnected in the middle of a turn before completion.", + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamDisconnected" + ], + "title": "ResponseStreamDisconnectedCodexErrorInfo", + "type": "object" + }, + { + "additionalProperties": false, + "description": "Reached the retry limit for responses.", + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseTooManyFailedAttempts" + ], + "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", + "type": "object" + } + ] + }, + "CollabAgentRef": { + "properties": { + "agent_nickname": { + "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agent_role": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the receiver/new agent." + } + }, + "required": [ + "thread_id" + ], + "type": "object" + }, + "CollabAgentState": { + "properties": { + "message": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/CollabAgentStatus" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "CollabAgentStatus": { + "enum": [ + "pendingInit", + "running", + "completed", + "errored", + "shutdown", + "notFound" + ], + "type": "string" + }, + "CollabAgentStatusEntry": { + "properties": { + "agent_nickname": { + "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agent_role": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/AgentStatus" + } + ], + "description": "Last known status of the agent." + }, + "thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the receiver/new agent." + } + }, + "required": [ + "status", + "thread_id" + ], + "type": "object" + }, + "CollabAgentTool": { + "enum": [ + "spawnAgent", + "sendInput", + "resumeAgent", + "wait", + "closeAgent" + ], + "type": "string" + }, + "CollabAgentToolCallStatus": { + "enum": [ + "inProgress", + "completed", + "failed" + ], + "type": "string" + }, + "CollaborationMode": { + "description": "Collaboration mode for a Codex session.", + "properties": { + "mode": { + "$ref": "#/definitions/ModeKind" + }, + "settings": { + "$ref": "#/definitions/Settings" + } + }, + "required": [ + "mode", + "settings" + ], + "type": "object" + }, + "CollaborationModeMask": { + "description": "EXPERIMENTAL - collaboration mode preset metadata for clients.", + "properties": { + "mode": { + "anyOf": [ + { + "$ref": "#/definitions/ModeKind" + }, + { + "type": "null" + } + ] + }, + "model": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "reasoning_effort": { + "anyOf": [ + { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "CommandAction": { + "oneOf": [ + { + "properties": { + "command": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "enum": [ + "read" + ], + "title": "ReadCommandActionType", + "type": "string" + } + }, + "required": [ + "command", + "name", + "path", + "type" + ], + "title": "ReadCommandAction", + "type": "object" + }, + { + "properties": { + "command": { + "type": "string" + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "listFiles" + ], + "title": "ListFilesCommandActionType", + "type": "string" + } + }, + "required": [ + "command", + "type" + ], + "title": "ListFilesCommandAction", + "type": "object" + }, + { + "properties": { + "command": { + "type": "string" + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "search" + ], + "title": "SearchCommandActionType", + "type": "string" + } + }, + "required": [ + "command", + "type" + ], + "title": "SearchCommandAction", + "type": "object" + }, + { + "properties": { + "command": { + "type": "string" + }, + "type": { + "enum": [ + "unknown" + ], + "title": "UnknownCommandActionType", + "type": "string" + } + }, + "required": [ + "command", + "type" + ], + "title": "UnknownCommandAction", + "type": "object" + } + ] + }, + "CommandExecParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "command": { + "items": { + "type": "string" + }, + "type": "array" + }, + "cwd": { + "type": [ + "string", + "null" + ] + }, + "sandboxPolicy": { + "anyOf": [ + { + "$ref": "#/definitions/SandboxPolicy" + }, + { + "type": "null" + } + ] + }, + "timeoutMs": { + "format": "int64", + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "command" + ], + "title": "CommandExecParams", + "type": "object" + }, + "CommandExecResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "exitCode": { + "format": "int32", + "type": "integer" + }, + "stderr": { + "type": "string" + }, + "stdout": { + "type": "string" + } + }, + "required": [ + "exitCode", + "stderr", + "stdout" + ], + "title": "CommandExecResponse", + "type": "object" + }, + "CommandExecutionOutputDeltaNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "delta": { + "type": "string" + }, + "itemId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "delta", + "itemId", + "threadId", + "turnId" + ], + "title": "CommandExecutionOutputDeltaNotification", + "type": "object" + }, + "CommandExecutionStatus": { + "enum": [ + "inProgress", + "completed", + "failed", + "declined" + ], + "type": "string" + }, + "Config": { + "additionalProperties": true, + "properties": { + "analytics": { + "anyOf": [ + { + "$ref": "#/definitions/AnalyticsConfig" + }, + { + "type": "null" + } + ] + }, + "approval_policy": { + "anyOf": [ + { + "$ref": "#/definitions/AskForApproval" + }, + { + "type": "null" + } + ] + }, + "compact_prompt": { + "type": [ + "string", + "null" + ] + }, + "developer_instructions": { + "type": [ + "string", + "null" + ] + }, + "forced_chatgpt_workspace_id": { + "type": [ + "string", + "null" + ] + }, + "forced_login_method": { + "anyOf": [ + { + "$ref": "#/definitions/ForcedLoginMethod" + }, + { + "type": "null" + } + ] + }, + "instructions": { + "type": [ + "string", + "null" + ] + }, + "model": { + "type": [ + "string", + "null" + ] + }, + "model_auto_compact_token_limit": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "model_context_window": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "model_provider": { + "type": [ + "string", + "null" + ] + }, + "model_reasoning_effort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "model_reasoning_summary": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningSummary" + }, + { + "type": "null" + } + ] + }, + "model_verbosity": { + "anyOf": [ + { + "$ref": "#/definitions/Verbosity" + }, + { + "type": "null" + } + ] + }, + "profile": { + "type": [ + "string", + "null" + ] + }, + "profiles": { + "additionalProperties": { + "$ref": "#/definitions/ProfileV2" + }, + "default": {}, + "type": "object" + }, + "review_model": { + "type": [ + "string", + "null" + ] + }, + "sandbox_mode": { + "anyOf": [ + { + "$ref": "#/definitions/SandboxMode" + }, + { + "type": "null" + } + ] + }, + "sandbox_workspace_write": { + "anyOf": [ + { + "$ref": "#/definitions/SandboxWorkspaceWrite" + }, + { + "type": "null" + } + ] + }, + "tools": { + "anyOf": [ + { + "$ref": "#/definitions/ToolsV2" + }, + { + "type": "null" + } + ] + }, + "web_search": { + "anyOf": [ + { + "$ref": "#/definitions/WebSearchMode" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "ConfigBatchWriteParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "edits": { + "items": { + "$ref": "#/definitions/ConfigEdit" + }, + "type": "array" + }, + "expectedVersion": { + "type": [ + "string", + "null" + ] + }, + "filePath": { + "description": "Path to the config file to write; defaults to the user's `config.toml` when omitted.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "edits" + ], + "title": "ConfigBatchWriteParams", + "type": "object" + }, + "ConfigEdit": { + "properties": { + "keyPath": { + "type": "string" + }, + "mergeStrategy": { + "$ref": "#/definitions/MergeStrategy" + }, + "value": true + }, + "required": [ + "keyPath", + "mergeStrategy", + "value" + ], + "type": "object" + }, + "ConfigLayer": { + "properties": { + "config": true, + "disabledReason": { + "type": [ + "string", + "null" + ] + }, + "name": { + "$ref": "#/definitions/ConfigLayerSource" + }, + "version": { + "type": "string" + } + }, + "required": [ + "config", + "name", + "version" + ], + "type": "object" + }, + "ConfigLayerMetadata": { + "properties": { + "name": { + "$ref": "#/definitions/ConfigLayerSource" + }, + "version": { + "type": "string" + } + }, + "required": [ + "name", + "version" + ], + "type": "object" + }, + "ConfigLayerSource": { + "oneOf": [ + { + "description": "Managed preferences layer delivered by MDM (macOS only).", + "properties": { + "domain": { + "type": "string" + }, + "key": { + "type": "string" + }, + "type": { + "enum": [ + "mdm" + ], + "title": "MdmConfigLayerSourceType", + "type": "string" + } + }, + "required": [ + "domain", + "key", + "type" + ], + "title": "MdmConfigLayerSource", + "type": "object" + }, + { + "description": "Managed config layer from a file (usually `managed_config.toml`).", + "properties": { + "file": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "This is the path to the system config.toml file, though it is not guaranteed to exist." + }, + "type": { + "enum": [ + "system" + ], + "title": "SystemConfigLayerSourceType", + "type": "string" + } + }, + "required": [ + "file", + "type" + ], + "title": "SystemConfigLayerSource", + "type": "object" + }, + { + "description": "User config layer from $CODEX_HOME/config.toml. This layer is special in that it is expected to be: - writable by the user - generally outside the workspace directory", + "properties": { + "file": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "This is the path to the user's config.toml file, though it is not guaranteed to exist." + }, + "type": { + "enum": [ + "user" + ], + "title": "UserConfigLayerSourceType", + "type": "string" + } + }, + "required": [ + "file", + "type" + ], + "title": "UserConfigLayerSource", + "type": "object" + }, + { + "description": "Path to a .codex/ folder within a project. There could be multiple of these between `cwd` and the project/repo root.", + "properties": { + "dotCodexFolder": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "project" + ], + "title": "ProjectConfigLayerSourceType", + "type": "string" + } + }, + "required": [ + "dotCodexFolder", + "type" + ], + "title": "ProjectConfigLayerSource", + "type": "object" + }, + { + "description": "Session-layer overrides supplied via `-c`/`--config`.", + "properties": { + "type": { + "enum": [ + "sessionFlags" + ], + "title": "SessionFlagsConfigLayerSourceType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "SessionFlagsConfigLayerSource", + "type": "object" + }, + { + "description": "`managed_config.toml` was designed to be a config that was loaded as the last layer on top of everything else. This scheme did not quite work out as intended, but we keep this variant as a \"best effort\" while we phase out `managed_config.toml` in favor of `requirements.toml`.", + "properties": { + "file": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "legacyManagedConfigTomlFromFile" + ], + "title": "LegacyManagedConfigTomlFromFileConfigLayerSourceType", + "type": "string" + } + }, + "required": [ + "file", + "type" + ], + "title": "LegacyManagedConfigTomlFromFileConfigLayerSource", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "legacyManagedConfigTomlFromMdm" + ], + "title": "LegacyManagedConfigTomlFromMdmConfigLayerSourceType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "LegacyManagedConfigTomlFromMdmConfigLayerSource", + "type": "object" + } + ] + }, + "ConfigReadParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cwd": { + "description": "Optional working directory to resolve project config layers. If specified, return the effective config as seen from that directory (i.e., including any project layers between `cwd` and the project/repo root).", + "type": [ + "string", + "null" + ] + }, + "includeLayers": { + "default": false, + "type": "boolean" + } + }, + "title": "ConfigReadParams", + "type": "object" + }, + "ConfigReadResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "config": { + "$ref": "#/definitions/Config" + }, + "layers": { + "items": { + "$ref": "#/definitions/ConfigLayer" + }, + "type": [ + "array", + "null" + ] + }, + "origins": { + "additionalProperties": { + "$ref": "#/definitions/ConfigLayerMetadata" + }, + "type": "object" + } + }, + "required": [ + "config", + "origins" + ], + "title": "ConfigReadResponse", + "type": "object" + }, + "ConfigRequirements": { + "properties": { + "allowedApprovalPolicies": { + "items": { + "$ref": "#/definitions/AskForApproval" + }, + "type": [ + "array", + "null" + ] + }, + "allowedSandboxModes": { + "items": { + "$ref": "#/definitions/SandboxMode" + }, + "type": [ + "array", + "null" + ] + }, + "allowedWebSearchModes": { + "items": { + "$ref": "#/definitions/WebSearchMode" + }, + "type": [ + "array", + "null" + ] + }, + "enforceResidency": { + "anyOf": [ + { + "$ref": "#/definitions/ResidencyRequirement" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "ConfigRequirementsReadResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "requirements": { + "anyOf": [ + { + "$ref": "#/definitions/ConfigRequirements" + }, + { + "type": "null" + } + ], + "description": "Null if no requirements are configured (e.g. no requirements.toml/MDM entries)." + } + }, + "title": "ConfigRequirementsReadResponse", + "type": "object" + }, + "ConfigValueWriteParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "expectedVersion": { + "type": [ + "string", + "null" + ] + }, + "filePath": { + "description": "Path to the config file to write; defaults to the user's `config.toml` when omitted.", + "type": [ + "string", + "null" + ] + }, + "keyPath": { + "type": "string" + }, + "mergeStrategy": { + "$ref": "#/definitions/MergeStrategy" + }, + "value": true + }, + "required": [ + "keyPath", + "mergeStrategy", + "value" + ], + "title": "ConfigValueWriteParams", + "type": "object" + }, + "ConfigWarningNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "details": { + "description": "Optional extra guidance or error details.", + "type": [ + "string", + "null" + ] + }, + "path": { + "description": "Optional path to the config file that triggered the warning.", + "type": [ + "string", + "null" + ] + }, + "range": { + "anyOf": [ + { + "$ref": "#/definitions/TextRange" + }, + { + "type": "null" + } + ], + "description": "Optional range for the error location inside the config file." + }, + "summary": { + "description": "Concise summary of the warning.", + "type": "string" + } + }, + "required": [ + "summary" + ], + "title": "ConfigWarningNotification", + "type": "object" + }, + "ConfigWriteResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "filePath": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Canonical path to the config file that was written." + }, + "overriddenMetadata": { + "anyOf": [ + { + "$ref": "#/definitions/OverriddenMetadata" + }, + { + "type": "null" + } + ] + }, + "status": { + "$ref": "#/definitions/WriteStatus" + }, + "version": { + "type": "string" + } + }, + "required": [ + "filePath", + "status", + "version" + ], + "title": "ConfigWriteResponse", + "type": "object" + }, + "ContentItem": { + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "input_text" + ], + "title": "InputTextContentItemType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "InputTextContentItem", + "type": "object" + }, + { + "properties": { + "image_url": { + "type": "string" + }, + "type": { + "enum": [ + "input_image" + ], + "title": "InputImageContentItemType", + "type": "string" + } + }, + "required": [ + "image_url", + "type" + ], + "title": "InputImageContentItem", + "type": "object" + }, + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "output_text" + ], + "title": "OutputTextContentItemType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "OutputTextContentItem", + "type": "object" + } + ] + }, + "ContextCompactedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Deprecated: Use `ContextCompaction` item type instead.", + "properties": { + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "threadId", + "turnId" + ], + "title": "ContextCompactedNotification", + "type": "object" + }, + "CreditsSnapshot": { + "properties": { + "balance": { + "type": [ + "string", + "null" + ] + }, + "hasCredits": { + "type": "boolean" + }, + "unlimited": { + "type": "boolean" + } + }, + "required": [ + "hasCredits", + "unlimited" + ], + "type": "object" + }, + "CustomPrompt": { + "properties": { + "argument_hint": { + "type": [ + "string", + "null" + ] + }, + "content": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "content", + "name", + "path" + ], + "type": "object" + }, + "DeprecationNoticeNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "details": { + "description": "Optional extra guidance, such as migration steps or rationale.", + "type": [ + "string", + "null" + ] + }, + "summary": { + "description": "Concise summary of what is deprecated.", + "type": "string" + } + }, + "required": [ + "summary" + ], + "title": "DeprecationNoticeNotification", + "type": "object" + }, + "Duration": { + "properties": { + "nanos": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "secs": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "nanos", + "secs" + ], + "type": "object" + }, + "DynamicToolCallOutputContentItem": { + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "inputText" + ], + "title": "InputTextDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "InputTextDynamicToolCallOutputContentItem", + "type": "object" + }, + { + "properties": { + "imageUrl": { + "type": "string" + }, + "type": { + "enum": [ + "inputImage" + ], + "title": "InputImageDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "imageUrl", + "type" + ], + "title": "InputImageDynamicToolCallOutputContentItem", + "type": "object" + } + ] + }, + "DynamicToolCallStatus": { + "enum": [ + "inProgress", + "completed", + "failed" + ], + "type": "string" + }, + "DynamicToolSpec": { + "properties": { + "description": { + "type": "string" + }, + "inputSchema": true, + "name": { + "type": "string" + } + }, + "required": [ + "description", + "inputSchema", + "name" + ], + "type": "object" + }, + "ErrorNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "error": { + "$ref": "#/definitions/TurnError" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + }, + "willRetry": { + "type": "boolean" + } + }, + "required": [ + "error", + "threadId", + "turnId", + "willRetry" + ], + "title": "ErrorNotification", + "type": "object" + }, + "EventMsg": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Response event from the agent NOTE: Make sure none of these values have optional types, as it will mess up the extension code-gen.", + "oneOf": [ + { + "description": "Error while executing a submission", + "properties": { + "codex_error_info": { + "anyOf": [ + { + "$ref": "#/definitions/CodexErrorInfo" + }, + { + "type": "null" + } + ], + "default": null + }, + "message": { + "type": "string" + }, + "type": { + "enum": [ + "error" + ], + "title": "ErrorEventMsgType", + "type": "string" + } + }, + "required": [ + "message", + "type" + ], + "title": "ErrorEventMsg", + "type": "object" + }, + { + "description": "Warning issued while processing a submission. Unlike `Error`, this indicates the turn continued but the user should still be notified.", + "properties": { + "message": { + "type": "string" + }, + "type": { + "enum": [ + "warning" + ], + "title": "WarningEventMsgType", + "type": "string" + } + }, + "required": [ + "message", + "type" + ], + "title": "WarningEventMsg", + "type": "object" + }, + { + "description": "Realtime conversation lifecycle start event.", + "properties": { + "session_id": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "realtime_conversation_started" + ], + "title": "RealtimeConversationStartedEventMsgType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RealtimeConversationStartedEventMsg", + "type": "object" + }, + { + "description": "Realtime conversation streaming payload event.", + "properties": { + "payload": { + "$ref": "#/definitions/RealtimeEvent" + }, + "type": { + "enum": [ + "realtime_conversation_realtime" + ], + "title": "RealtimeConversationRealtimeEventMsgType", + "type": "string" + } + }, + "required": [ + "payload", + "type" + ], + "title": "RealtimeConversationRealtimeEventMsg", + "type": "object" + }, + { + "description": "Realtime conversation lifecycle close event.", + "properties": { + "reason": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "realtime_conversation_closed" + ], + "title": "RealtimeConversationClosedEventMsgType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RealtimeConversationClosedEventMsg", + "type": "object" + }, + { + "description": "Model routing changed from the requested model to a different model.", + "properties": { + "from_model": { + "type": "string" + }, + "reason": { + "$ref": "#/definitions/ModelRerouteReason" + }, + "to_model": { + "type": "string" + }, + "type": { + "enum": [ + "model_reroute" + ], + "title": "ModelRerouteEventMsgType", + "type": "string" + } + }, + "required": [ + "from_model", + "reason", + "to_model", + "type" + ], + "title": "ModelRerouteEventMsg", + "type": "object" + }, + { + "description": "Conversation history was compacted (either automatically or manually).", + "properties": { + "type": { + "enum": [ + "context_compacted" + ], + "title": "ContextCompactedEventMsgType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ContextCompactedEventMsg", + "type": "object" + }, + { + "description": "Conversation history was rolled back by dropping the last N user turns.", + "properties": { + "num_turns": { + "description": "Number of user turns that were removed from context.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "type": { + "enum": [ + "thread_rolled_back" + ], + "title": "ThreadRolledBackEventMsgType", + "type": "string" + } + }, + "required": [ + "num_turns", + "type" + ], + "title": "ThreadRolledBackEventMsg", + "type": "object" + }, + { + "description": "Agent has started a turn. v1 wire format uses `task_started`; accept `turn_started` for v2 interop.", + "properties": { + "collaboration_mode_kind": { + "allOf": [ + { + "$ref": "#/definitions/ModeKind" + } + ], + "default": "default" + }, + "model_context_window": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "turn_id": { + "type": "string" + }, + "type": { + "enum": [ + "task_started" + ], + "title": "TaskStartedEventMsgType", + "type": "string" + } + }, + "required": [ + "turn_id", + "type" + ], + "title": "TaskStartedEventMsg", + "type": "object" + }, + { + "description": "Agent has completed all actions. v1 wire format uses `task_complete`; accept `turn_complete` for v2 interop.", + "properties": { + "last_agent_message": { + "type": [ + "string", + "null" + ] + }, + "turn_id": { + "type": "string" + }, + "type": { + "enum": [ + "task_complete" + ], + "title": "TaskCompleteEventMsgType", + "type": "string" + } + }, + "required": [ + "turn_id", + "type" + ], + "title": "TaskCompleteEventMsg", + "type": "object" + }, + { + "description": "Usage update for the current session, including totals and last turn. Optional means unknown — UIs should not display when `None`.", + "properties": { + "info": { + "anyOf": [ + { + "$ref": "#/definitions/TokenUsageInfo" + }, + { + "type": "null" + } + ] + }, + "rate_limits": { + "anyOf": [ + { + "$ref": "#/definitions/RateLimitSnapshot" + }, + { + "type": "null" + } + ] + }, + "type": { + "enum": [ + "token_count" + ], + "title": "TokenCountEventMsgType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "TokenCountEventMsg", + "type": "object" + }, + { + "description": "Agent text output message", + "properties": { + "message": { + "type": "string" + }, + "phase": { + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase" + }, + { + "type": "null" + } + ], + "default": null + }, + "type": { + "enum": [ + "agent_message" + ], + "title": "AgentMessageEventMsgType", + "type": "string" + } + }, + "required": [ + "message", + "type" + ], + "title": "AgentMessageEventMsg", + "type": "object" + }, + { + "description": "User/system input message (what was sent to the model)", + "properties": { + "images": { + "description": "Image URLs sourced from `UserInput::Image`. These are safe to replay in legacy UI history events and correspond to images sent to the model.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "local_images": { + "default": [], + "description": "Local file paths sourced from `UserInput::LocalImage`. These are kept so the UI can reattach images when editing history, and should not be sent to the model or treated as API-ready URLs.", + "items": { + "type": "string" + }, + "type": "array" + }, + "message": { + "type": "string" + }, + "text_elements": { + "default": [], + "description": "UI-defined spans within `message` used to render or persist special elements.", + "items": { + "$ref": "#/definitions/TextElement" + }, + "type": "array" + }, + "type": { + "enum": [ + "user_message" + ], + "title": "UserMessageEventMsgType", + "type": "string" + } + }, + "required": [ + "message", + "type" + ], + "title": "UserMessageEventMsg", + "type": "object" + }, + { + "description": "Agent text output delta message", + "properties": { + "delta": { + "type": "string" + }, + "type": { + "enum": [ + "agent_message_delta" + ], + "title": "AgentMessageDeltaEventMsgType", + "type": "string" + } + }, + "required": [ + "delta", + "type" + ], + "title": "AgentMessageDeltaEventMsg", + "type": "object" + }, + { + "description": "Reasoning event from agent.", + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "agent_reasoning" + ], + "title": "AgentReasoningEventMsgType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "AgentReasoningEventMsg", + "type": "object" + }, + { + "description": "Agent reasoning delta event from agent.", + "properties": { + "delta": { + "type": "string" + }, + "type": { + "enum": [ + "agent_reasoning_delta" + ], + "title": "AgentReasoningDeltaEventMsgType", + "type": "string" + } + }, + "required": [ + "delta", + "type" + ], + "title": "AgentReasoningDeltaEventMsg", + "type": "object" + }, + { + "description": "Raw chain-of-thought from agent.", + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "agent_reasoning_raw_content" + ], + "title": "AgentReasoningRawContentEventMsgType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "AgentReasoningRawContentEventMsg", + "type": "object" + }, + { + "description": "Agent reasoning content delta event from agent.", + "properties": { + "delta": { + "type": "string" + }, + "type": { + "enum": [ + "agent_reasoning_raw_content_delta" + ], + "title": "AgentReasoningRawContentDeltaEventMsgType", + "type": "string" + } + }, + "required": [ + "delta", + "type" + ], + "title": "AgentReasoningRawContentDeltaEventMsg", + "type": "object" + }, + { + "description": "Signaled when the model begins a new reasoning summary section (e.g., a new titled block).", + "properties": { + "item_id": { + "default": "", + "type": "string" + }, + "summary_index": { + "default": 0, + "format": "int64", + "type": "integer" + }, + "type": { + "enum": [ + "agent_reasoning_section_break" + ], + "title": "AgentReasoningSectionBreakEventMsgType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "AgentReasoningSectionBreakEventMsg", + "type": "object" + }, + { + "description": "Ack the client's configure message.", + "properties": { + "approval_policy": { + "allOf": [ + { + "$ref": "#/definitions/AskForApproval" + } + ], + "description": "When to escalate for approval for execution" + }, + "cwd": { + "description": "Working directory that should be treated as the *root* of the session.", + "type": "string" + }, + "forked_from_id": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadId" + }, + { + "type": "null" + } + ] + }, + "history_entry_count": { + "description": "Current number of entries in the history log.", + "format": "uint", + "minimum": 0.0, + "type": "integer" + }, + "history_log_id": { + "description": "Identifier of the history log file (inode on Unix, 0 otherwise).", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "initial_messages": { + "description": "Optional initial messages (as events) for resumed sessions. When present, UIs can use these to seed the history.", + "items": { + "$ref": "#/definitions/EventMsg" + }, + "type": [ + "array", + "null" + ] + }, + "model": { + "description": "Tell the client what model is being queried.", + "type": "string" + }, + "model_provider_id": { + "type": "string" + }, + "network_proxy": { + "anyOf": [ + { + "$ref": "#/definitions/SessionNetworkProxyRuntime" + }, + { + "type": "null" + } + ], + "description": "Runtime proxy bind addresses, when the managed proxy was started for this session." + }, + "reasoning_effort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ], + "description": "The effort the model is putting into reasoning about the user's request." + }, + "rollout_path": { + "description": "Path in which the rollout is stored. Can be `None` for ephemeral threads", + "type": [ + "string", + "null" + ] + }, + "sandbox_policy": { + "allOf": [ + { + "$ref": "#/definitions/SandboxPolicy" + } + ], + "description": "How to sandbox commands executed in the system" + }, + "session_id": { + "$ref": "#/definitions/ThreadId" + }, + "thread_name": { + "description": "Optional user-facing thread name (may be unset).", + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "session_configured" + ], + "title": "SessionConfiguredEventMsgType", + "type": "string" + } + }, + "required": [ + "approval_policy", + "cwd", + "history_entry_count", + "history_log_id", + "model", + "model_provider_id", + "sandbox_policy", + "session_id", + "type" + ], + "title": "SessionConfiguredEventMsg", + "type": "object" + }, + { + "description": "Updated session metadata (e.g., thread name changes).", + "properties": { + "thread_id": { + "$ref": "#/definitions/ThreadId" + }, + "thread_name": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "thread_name_updated" + ], + "title": "ThreadNameUpdatedEventMsgType", + "type": "string" + } + }, + "required": [ + "thread_id", + "type" + ], + "title": "ThreadNameUpdatedEventMsg", + "type": "object" + }, + { + "description": "Incremental MCP startup progress updates.", + "properties": { + "server": { + "description": "Server name being started.", + "type": "string" + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/McpStartupStatus" + } + ], + "description": "Current startup status." + }, + "type": { + "enum": [ + "mcp_startup_update" + ], + "title": "McpStartupUpdateEventMsgType", + "type": "string" + } + }, + "required": [ + "server", + "status", + "type" + ], + "title": "McpStartupUpdateEventMsg", + "type": "object" + }, + { + "description": "Aggregate MCP startup completion summary.", + "properties": { + "cancelled": { + "items": { + "type": "string" + }, + "type": "array" + }, + "failed": { + "items": { + "$ref": "#/definitions/McpStartupFailure" + }, + "type": "array" + }, + "ready": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "enum": [ + "mcp_startup_complete" + ], + "title": "McpStartupCompleteEventMsgType", + "type": "string" + } + }, + "required": [ + "cancelled", + "failed", + "ready", + "type" + ], + "title": "McpStartupCompleteEventMsg", + "type": "object" + }, + { + "properties": { + "call_id": { + "description": "Identifier so this can be paired with the McpToolCallEnd event.", + "type": "string" + }, + "invocation": { + "$ref": "#/definitions/McpInvocation" + }, + "type": { + "enum": [ + "mcp_tool_call_begin" + ], + "title": "McpToolCallBeginEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "invocation", + "type" + ], + "title": "McpToolCallBeginEventMsg", + "type": "object" + }, + { + "properties": { + "call_id": { + "description": "Identifier for the corresponding McpToolCallBegin that finished.", + "type": "string" + }, + "duration": { + "$ref": "#/definitions/Duration" + }, + "invocation": { + "$ref": "#/definitions/McpInvocation" + }, + "result": { + "allOf": [ + { + "$ref": "#/definitions/Result_of_CallToolResult_or_String" + } + ], + "description": "Result of the tool call. Note this could be an error." + }, + "type": { + "enum": [ + "mcp_tool_call_end" + ], + "title": "McpToolCallEndEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "duration", + "invocation", + "result", + "type" + ], + "title": "McpToolCallEndEventMsg", + "type": "object" + }, + { + "properties": { + "call_id": { + "type": "string" + }, + "type": { + "enum": [ + "web_search_begin" + ], + "title": "WebSearchBeginEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "type" + ], + "title": "WebSearchBeginEventMsg", + "type": "object" + }, + { + "properties": { + "action": { + "$ref": "#/definitions/WebSearchAction" + }, + "call_id": { + "type": "string" + }, + "query": { + "type": "string" + }, + "type": { + "enum": [ + "web_search_end" + ], + "title": "WebSearchEndEventMsgType", + "type": "string" + } + }, + "required": [ + "action", + "call_id", + "query", + "type" + ], + "title": "WebSearchEndEventMsg", + "type": "object" + }, + { + "description": "Notification that the server is about to execute a command.", + "properties": { + "call_id": { + "description": "Identifier so this can be paired with the ExecCommandEnd event.", + "type": "string" + }, + "command": { + "description": "The command to be executed.", + "items": { + "type": "string" + }, + "type": "array" + }, + "cwd": { + "description": "The command's working directory if not the default cwd for the agent.", + "type": "string" + }, + "interaction_input": { + "description": "Raw input sent to a unified exec session (if this is an interaction event).", + "type": [ + "string", + "null" + ] + }, + "parsed_cmd": { + "items": { + "$ref": "#/definitions/ParsedCommand" + }, + "type": "array" + }, + "process_id": { + "description": "Identifier for the underlying PTY process (when available).", + "type": [ + "string", + "null" + ] + }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/ExecCommandSource" + } + ], + "default": "agent", + "description": "Where the command originated. Defaults to Agent for backward compatibility." + }, + "turn_id": { + "description": "Turn ID that this command belongs to.", + "type": "string" + }, + "type": { + "enum": [ + "exec_command_begin" + ], + "title": "ExecCommandBeginEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "command", + "cwd", + "parsed_cmd", + "turn_id", + "type" + ], + "title": "ExecCommandBeginEventMsg", + "type": "object" + }, + { + "description": "Incremental chunk of output from a running command.", + "properties": { + "call_id": { + "description": "Identifier for the ExecCommandBegin that produced this chunk.", + "type": "string" + }, + "chunk": { + "description": "Raw bytes from the stream (may not be valid UTF-8).", + "type": "string" + }, + "stream": { + "allOf": [ + { + "$ref": "#/definitions/ExecOutputStream" + } + ], + "description": "Which stream produced this chunk." + }, + "type": { + "enum": [ + "exec_command_output_delta" + ], + "title": "ExecCommandOutputDeltaEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "chunk", + "stream", + "type" + ], + "title": "ExecCommandOutputDeltaEventMsg", + "type": "object" + }, + { + "description": "Terminal interaction for an in-progress command (stdin sent and stdout observed).", + "properties": { + "call_id": { + "description": "Identifier for the ExecCommandBegin that produced this chunk.", + "type": "string" + }, + "process_id": { + "description": "Process id associated with the running command.", + "type": "string" + }, + "stdin": { + "description": "Stdin sent to the running session.", + "type": "string" + }, + "type": { + "enum": [ + "terminal_interaction" + ], + "title": "TerminalInteractionEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "process_id", + "stdin", + "type" + ], + "title": "TerminalInteractionEventMsg", + "type": "object" + }, + { + "properties": { + "aggregated_output": { + "default": "", + "description": "Captured aggregated output", + "type": "string" + }, + "call_id": { + "description": "Identifier for the ExecCommandBegin that finished.", + "type": "string" + }, + "command": { + "description": "The command that was executed.", + "items": { + "type": "string" + }, + "type": "array" + }, + "cwd": { + "description": "The command's working directory if not the default cwd for the agent.", + "type": "string" + }, + "duration": { + "allOf": [ + { + "$ref": "#/definitions/Duration" + } + ], + "description": "The duration of the command execution." + }, + "exit_code": { + "description": "The command's exit code.", + "format": "int32", + "type": "integer" + }, + "formatted_output": { + "description": "Formatted output from the command, as seen by the model.", + "type": "string" + }, + "interaction_input": { + "description": "Raw input sent to a unified exec session (if this is an interaction event).", + "type": [ + "string", + "null" + ] + }, + "parsed_cmd": { + "items": { + "$ref": "#/definitions/ParsedCommand" + }, + "type": "array" + }, + "process_id": { + "description": "Identifier for the underlying PTY process (when available).", + "type": [ + "string", + "null" + ] + }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/ExecCommandSource" + } + ], + "default": "agent", + "description": "Where the command originated. Defaults to Agent for backward compatibility." + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/ExecCommandStatus" + } + ], + "description": "Completion status for this command execution." + }, + "stderr": { + "description": "Captured stderr", + "type": "string" + }, + "stdout": { + "description": "Captured stdout", + "type": "string" + }, + "turn_id": { + "description": "Turn ID that this command belongs to.", + "type": "string" + }, + "type": { + "enum": [ + "exec_command_end" + ], + "title": "ExecCommandEndEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "command", + "cwd", + "duration", + "exit_code", + "formatted_output", + "parsed_cmd", + "status", + "stderr", + "stdout", + "turn_id", + "type" + ], + "title": "ExecCommandEndEventMsg", + "type": "object" + }, + { + "description": "Notification that the agent attached a local image via the view_image tool.", + "properties": { + "call_id": { + "description": "Identifier for the originating tool call.", + "type": "string" + }, + "path": { + "description": "Local filesystem path provided to the tool.", + "type": "string" + }, + "type": { + "enum": [ + "view_image_tool_call" + ], + "title": "ViewImageToolCallEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "path", + "type" + ], + "title": "ViewImageToolCallEventMsg", + "type": "object" + }, + { + "properties": { + "additional_permissions": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Optional additional filesystem permissions requested for this command." + }, + "approval_id": { + "description": "Identifier for this specific approval callback.\n\nWhen absent, the approval is for the command item itself (`call_id`). This is present for subcommand approvals (via execve intercept).", + "type": [ + "string", + "null" + ] + }, + "available_decisions": { + "description": "Ordered list of decisions the client may present for this prompt.\n\nWhen absent, clients should derive the legacy default set from the other fields on this request.", + "items": { + "$ref": "#/definitions/ReviewDecision" + }, + "type": [ + "array", + "null" + ] + }, + "call_id": { + "description": "Identifier for the associated command execution item.", + "type": "string" + }, + "command": { + "description": "The command to be executed.", + "items": { + "type": "string" + }, + "type": "array" + }, + "cwd": { + "description": "The command's working directory.", + "type": "string" + }, + "network_approval_context": { + "anyOf": [ + { + "$ref": "#/definitions/NetworkApprovalContext" + }, + { + "type": "null" + } + ], + "description": "Optional network context for a blocked request that can be approved." + }, + "parsed_cmd": { + "items": { + "$ref": "#/definitions/ParsedCommand" + }, + "type": "array" + }, + "proposed_execpolicy_amendment": { + "description": "Proposed execpolicy amendment that can be applied to allow future runs.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "proposed_network_policy_amendments": { + "description": "Proposed network policy amendments (for example allow/deny this host in future).", + "items": { + "$ref": "#/definitions/NetworkPolicyAmendment" + }, + "type": [ + "array", + "null" + ] + }, + "reason": { + "description": "Optional human-readable reason for the approval (e.g. retry without sandbox).", + "type": [ + "string", + "null" + ] + }, + "turn_id": { + "default": "", + "description": "Turn ID that this command belongs to. Uses `#[serde(default)]` for backwards compatibility.", + "type": "string" + }, + "type": { + "enum": [ + "exec_approval_request" + ], + "title": "ExecApprovalRequestEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "command", + "cwd", + "parsed_cmd", + "type" + ], + "title": "ExecApprovalRequestEventMsg", + "type": "object" + }, + { + "properties": { + "call_id": { + "description": "Responses API call id for the associated tool call, if available.", + "type": "string" + }, + "questions": { + "items": { + "$ref": "#/definitions/RequestUserInputQuestion" + }, + "type": "array" + }, + "turn_id": { + "default": "", + "description": "Turn ID that this request belongs to. Uses `#[serde(default)]` for backwards compatibility.", + "type": "string" + }, + "type": { + "enum": [ + "request_user_input" + ], + "title": "RequestUserInputEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "questions", + "type" + ], + "title": "RequestUserInputEventMsg", + "type": "object" + }, + { + "properties": { + "arguments": true, + "callId": { + "type": "string" + }, + "tool": { + "type": "string" + }, + "turnId": { + "type": "string" + }, + "type": { + "enum": [ + "dynamic_tool_call_request" + ], + "title": "DynamicToolCallRequestEventMsgType", + "type": "string" + } + }, + "required": [ + "arguments", + "callId", + "tool", + "turnId", + "type" + ], + "title": "DynamicToolCallRequestEventMsg", + "type": "object" + }, + { + "properties": { + "arguments": { + "description": "Dynamic tool call arguments." + }, + "call_id": { + "description": "Identifier for the corresponding DynamicToolCallRequest.", + "type": "string" + }, + "content_items": { + "description": "Dynamic tool response content items.", + "items": { + "$ref": "#/definitions/DynamicToolCallOutputContentItem" + }, + "type": "array" + }, + "duration": { + "allOf": [ + { + "$ref": "#/definitions/Duration" + } + ], + "description": "The duration of the dynamic tool call." + }, + "error": { + "description": "Optional error text when the tool call failed before producing a response.", + "type": [ + "string", + "null" + ] + }, + "success": { + "description": "Whether the tool call succeeded.", + "type": "boolean" + }, + "tool": { + "description": "Dynamic tool name.", + "type": "string" + }, + "turn_id": { + "description": "Turn ID that this dynamic tool call belongs to.", + "type": "string" + }, + "type": { + "enum": [ + "dynamic_tool_call_response" + ], + "title": "DynamicToolCallResponseEventMsgType", + "type": "string" + } + }, + "required": [ + "arguments", + "call_id", + "content_items", + "duration", + "success", + "tool", + "turn_id", + "type" + ], + "title": "DynamicToolCallResponseEventMsg", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "message": { + "type": "string" + }, + "server_name": { + "type": "string" + }, + "type": { + "enum": [ + "elicitation_request" + ], + "title": "ElicitationRequestEventMsgType", + "type": "string" + } + }, + "required": [ + "id", + "message", + "server_name", + "type" + ], + "title": "ElicitationRequestEventMsg", + "type": "object" + }, + { + "properties": { + "call_id": { + "description": "Responses API call id for the associated patch apply call, if available.", + "type": "string" + }, + "changes": { + "additionalProperties": { + "$ref": "#/definitions/FileChange" + }, + "type": "object" + }, + "grant_root": { + "description": "When set, the agent is asking the user to allow writes under this root for the remainder of the session.", + "type": [ + "string", + "null" + ] + }, + "reason": { + "description": "Optional explanatory reason (e.g. request for extra write access).", + "type": [ + "string", + "null" + ] + }, + "turn_id": { + "default": "", + "description": "Turn ID that this patch belongs to. Uses `#[serde(default)]` for backwards compatibility with older senders.", + "type": "string" + }, + "type": { + "enum": [ + "apply_patch_approval_request" + ], + "title": "ApplyPatchApprovalRequestEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "changes", + "type" + ], + "title": "ApplyPatchApprovalRequestEventMsg", + "type": "object" + }, + { + "description": "Notification advising the user that something they are using has been deprecated and should be phased out.", + "properties": { + "details": { + "description": "Optional extra guidance, such as migration steps or rationale.", + "type": [ + "string", + "null" + ] + }, + "summary": { + "description": "Concise summary of what is deprecated.", + "type": "string" + }, + "type": { + "enum": [ + "deprecation_notice" + ], + "title": "DeprecationNoticeEventMsgType", + "type": "string" + } + }, + "required": [ + "summary", + "type" + ], + "title": "DeprecationNoticeEventMsg", + "type": "object" + }, + { + "properties": { + "message": { + "type": "string" + }, + "type": { + "enum": [ + "background_event" + ], + "title": "BackgroundEventEventMsgType", + "type": "string" + } + }, + "required": [ + "message", + "type" + ], + "title": "BackgroundEventEventMsg", + "type": "object" + }, + { + "properties": { + "message": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "undo_started" + ], + "title": "UndoStartedEventMsgType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "UndoStartedEventMsg", + "type": "object" + }, + { + "properties": { + "message": { + "type": [ + "string", + "null" + ] + }, + "success": { + "type": "boolean" + }, + "type": { + "enum": [ + "undo_completed" + ], + "title": "UndoCompletedEventMsgType", + "type": "string" + } + }, + "required": [ + "success", + "type" + ], + "title": "UndoCompletedEventMsg", + "type": "object" + }, + { + "description": "Notification that a model stream experienced an error or disconnect and the system is handling it (e.g., retrying with backoff).", + "properties": { + "additional_details": { + "default": null, + "description": "Optional details about the underlying stream failure (often the same human-readable message that is surfaced as the terminal error if retries are exhausted).", + "type": [ + "string", + "null" + ] + }, + "codex_error_info": { + "anyOf": [ + { + "$ref": "#/definitions/CodexErrorInfo" + }, + { + "type": "null" + } + ], + "default": null + }, + "message": { + "type": "string" + }, + "type": { + "enum": [ + "stream_error" + ], + "title": "StreamErrorEventMsgType", + "type": "string" + } + }, + "required": [ + "message", + "type" + ], + "title": "StreamErrorEventMsg", + "type": "object" + }, + { + "description": "Notification that the agent is about to apply a code patch. Mirrors `ExecCommandBegin` so front‑ends can show progress indicators.", + "properties": { + "auto_approved": { + "description": "If true, there was no ApplyPatchApprovalRequest for this patch.", + "type": "boolean" + }, + "call_id": { + "description": "Identifier so this can be paired with the PatchApplyEnd event.", + "type": "string" + }, + "changes": { + "additionalProperties": { + "$ref": "#/definitions/FileChange" + }, + "description": "The changes to be applied.", + "type": "object" + }, + "turn_id": { + "default": "", + "description": "Turn ID that this patch belongs to. Uses `#[serde(default)]` for backwards compatibility.", + "type": "string" + }, + "type": { + "enum": [ + "patch_apply_begin" + ], + "title": "PatchApplyBeginEventMsgType", + "type": "string" + } + }, + "required": [ + "auto_approved", + "call_id", + "changes", + "type" + ], + "title": "PatchApplyBeginEventMsg", + "type": "object" + }, + { + "description": "Notification that a patch application has finished.", + "properties": { + "call_id": { + "description": "Identifier for the PatchApplyBegin that finished.", + "type": "string" + }, + "changes": { + "additionalProperties": { + "$ref": "#/definitions/FileChange" + }, + "default": {}, + "description": "The changes that were applied (mirrors PatchApplyBeginEvent::changes).", + "type": "object" + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/PatchApplyStatus" + } + ], + "description": "Completion status for this patch application." + }, + "stderr": { + "description": "Captured stderr (parser errors, IO failures, etc.).", + "type": "string" + }, + "stdout": { + "description": "Captured stdout (summary printed by apply_patch).", + "type": "string" + }, + "success": { + "description": "Whether the patch was applied successfully.", + "type": "boolean" + }, + "turn_id": { + "default": "", + "description": "Turn ID that this patch belongs to. Uses `#[serde(default)]` for backwards compatibility.", + "type": "string" + }, + "type": { + "enum": [ + "patch_apply_end" + ], + "title": "PatchApplyEndEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "status", + "stderr", + "stdout", + "success", + "type" + ], + "title": "PatchApplyEndEventMsg", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "turn_diff" + ], + "title": "TurnDiffEventMsgType", + "type": "string" + }, + "unified_diff": { + "type": "string" + } + }, + "required": [ + "type", + "unified_diff" + ], + "title": "TurnDiffEventMsg", + "type": "object" + }, + { + "description": "Response to GetHistoryEntryRequest.", + "properties": { + "entry": { + "anyOf": [ + { + "$ref": "#/definitions/HistoryEntry" + }, + { + "type": "null" + } + ], + "description": "The entry at the requested offset, if available and parseable." + }, + "log_id": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "offset": { + "format": "uint", + "minimum": 0.0, + "type": "integer" + }, + "type": { + "enum": [ + "get_history_entry_response" + ], + "title": "GetHistoryEntryResponseEventMsgType", + "type": "string" + } + }, + "required": [ + "log_id", + "offset", + "type" + ], + "title": "GetHistoryEntryResponseEventMsg", + "type": "object" + }, + { + "description": "List of MCP tools available to the agent.", + "properties": { + "auth_statuses": { + "additionalProperties": { + "$ref": "#/definitions/McpAuthStatus" + }, + "description": "Authentication status for each configured MCP server.", + "type": "object" + }, + "resource_templates": { + "additionalProperties": { + "items": { + "$ref": "#/definitions/ResourceTemplate" + }, + "type": "array" + }, + "description": "Known resource templates grouped by server name.", + "type": "object" + }, + "resources": { + "additionalProperties": { + "items": { + "$ref": "#/definitions/Resource" + }, + "type": "array" + }, + "description": "Known resources grouped by server name.", + "type": "object" + }, + "tools": { + "additionalProperties": { + "$ref": "#/definitions/Tool" + }, + "description": "Fully qualified tool name -> tool definition.", + "type": "object" + }, + "type": { + "enum": [ + "mcp_list_tools_response" + ], + "title": "McpListToolsResponseEventMsgType", + "type": "string" + } + }, + "required": [ + "auth_statuses", + "resource_templates", + "resources", + "tools", + "type" + ], + "title": "McpListToolsResponseEventMsg", + "type": "object" + }, + { + "description": "List of custom prompts available to the agent.", + "properties": { + "custom_prompts": { + "items": { + "$ref": "#/definitions/CustomPrompt" + }, + "type": "array" + }, + "type": { + "enum": [ + "list_custom_prompts_response" + ], + "title": "ListCustomPromptsResponseEventMsgType", + "type": "string" + } + }, + "required": [ + "custom_prompts", + "type" + ], + "title": "ListCustomPromptsResponseEventMsg", + "type": "object" + }, + { + "description": "List of skills available to the agent.", + "properties": { + "skills": { + "items": { + "$ref": "#/definitions/SkillsListEntry" + }, + "type": "array" + }, + "type": { + "enum": [ + "list_skills_response" + ], + "title": "ListSkillsResponseEventMsgType", + "type": "string" + } + }, + "required": [ + "skills", + "type" + ], + "title": "ListSkillsResponseEventMsg", + "type": "object" + }, + { + "description": "List of remote skills available to the agent.", + "properties": { + "skills": { + "items": { + "$ref": "#/definitions/RemoteSkillSummary" + }, + "type": "array" + }, + "type": { + "enum": [ + "list_remote_skills_response" + ], + "title": "ListRemoteSkillsResponseEventMsgType", + "type": "string" + } + }, + "required": [ + "skills", + "type" + ], + "title": "ListRemoteSkillsResponseEventMsg", + "type": "object" + }, + { + "description": "Remote skill downloaded to local cache.", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "enum": [ + "remote_skill_downloaded" + ], + "title": "RemoteSkillDownloadedEventMsgType", + "type": "string" + } + }, + "required": [ + "id", + "name", + "path", + "type" + ], + "title": "RemoteSkillDownloadedEventMsg", + "type": "object" + }, + { + "description": "Notification that skill data may have been updated and clients may want to reload.", + "properties": { + "type": { + "enum": [ + "skills_update_available" + ], + "title": "SkillsUpdateAvailableEventMsgType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "SkillsUpdateAvailableEventMsg", + "type": "object" + }, + { + "properties": { + "explanation": { + "default": null, + "description": "Arguments for the `update_plan` todo/checklist tool (not plan mode).", + "type": [ + "string", + "null" + ] + }, + "plan": { + "items": { + "$ref": "#/definitions/PlanItemArg" + }, + "type": "array" + }, + "type": { + "enum": [ + "plan_update" + ], + "title": "PlanUpdateEventMsgType", + "type": "string" + } + }, + "required": [ + "plan", + "type" + ], + "title": "PlanUpdateEventMsg", + "type": "object" + }, + { + "properties": { + "reason": { + "$ref": "#/definitions/TurnAbortReason" + }, + "turn_id": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "turn_aborted" + ], + "title": "TurnAbortedEventMsgType", + "type": "string" + } + }, + "required": [ + "reason", + "type" + ], + "title": "TurnAbortedEventMsg", + "type": "object" + }, + { + "description": "Notification that the agent is shutting down.", + "properties": { + "type": { + "enum": [ + "shutdown_complete" + ], + "title": "ShutdownCompleteEventMsgType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ShutdownCompleteEventMsg", + "type": "object" + }, + { + "description": "Entered review mode.", + "properties": { + "target": { + "$ref": "#/definitions/ReviewTarget" + }, + "type": { + "enum": [ + "entered_review_mode" + ], + "title": "EnteredReviewModeEventMsgType", + "type": "string" + }, + "user_facing_hint": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "target", + "type" + ], + "title": "EnteredReviewModeEventMsg", + "type": "object" + }, + { + "description": "Exited review mode with an optional final result to apply.", + "properties": { + "review_output": { + "anyOf": [ + { + "$ref": "#/definitions/ReviewOutputEvent" + }, + { + "type": "null" + } + ] + }, + "type": { + "enum": [ + "exited_review_mode" + ], + "title": "ExitedReviewModeEventMsgType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ExitedReviewModeEventMsg", + "type": "object" + }, + { + "properties": { + "item": { + "$ref": "#/definitions/ResponseItem" + }, + "type": { + "enum": [ + "raw_response_item" + ], + "title": "RawResponseItemEventMsgType", + "type": "string" + } + }, + "required": [ + "item", + "type" + ], + "title": "RawResponseItemEventMsg", + "type": "object" + }, + { + "properties": { + "item": { + "$ref": "#/definitions/TurnItem" + }, + "thread_id": { + "$ref": "#/definitions/ThreadId" + }, + "turn_id": { + "type": "string" + }, + "type": { + "enum": [ + "item_started" + ], + "title": "ItemStartedEventMsgType", + "type": "string" + } + }, + "required": [ + "item", + "thread_id", + "turn_id", + "type" + ], + "title": "ItemStartedEventMsg", + "type": "object" + }, + { + "properties": { + "item": { + "$ref": "#/definitions/TurnItem" + }, + "thread_id": { + "$ref": "#/definitions/ThreadId" + }, + "turn_id": { + "type": "string" + }, + "type": { + "enum": [ + "item_completed" + ], + "title": "ItemCompletedEventMsgType", + "type": "string" + } + }, + "required": [ + "item", + "thread_id", + "turn_id", + "type" + ], + "title": "ItemCompletedEventMsg", + "type": "object" + }, + { + "properties": { + "delta": { + "type": "string" + }, + "item_id": { + "type": "string" + }, + "thread_id": { + "type": "string" + }, + "turn_id": { + "type": "string" + }, + "type": { + "enum": [ + "agent_message_content_delta" + ], + "title": "AgentMessageContentDeltaEventMsgType", + "type": "string" + } + }, + "required": [ + "delta", + "item_id", + "thread_id", + "turn_id", + "type" + ], + "title": "AgentMessageContentDeltaEventMsg", + "type": "object" + }, + { + "properties": { + "delta": { + "type": "string" + }, + "item_id": { + "type": "string" + }, + "thread_id": { + "type": "string" + }, + "turn_id": { + "type": "string" + }, + "type": { + "enum": [ + "plan_delta" + ], + "title": "PlanDeltaEventMsgType", + "type": "string" + } + }, + "required": [ + "delta", + "item_id", + "thread_id", + "turn_id", + "type" + ], + "title": "PlanDeltaEventMsg", + "type": "object" + }, + { + "properties": { + "delta": { + "type": "string" + }, + "item_id": { + "type": "string" + }, + "summary_index": { + "default": 0, + "format": "int64", + "type": "integer" + }, + "thread_id": { + "type": "string" + }, + "turn_id": { + "type": "string" + }, + "type": { + "enum": [ + "reasoning_content_delta" + ], + "title": "ReasoningContentDeltaEventMsgType", + "type": "string" + } + }, + "required": [ + "delta", + "item_id", + "thread_id", + "turn_id", + "type" + ], + "title": "ReasoningContentDeltaEventMsg", + "type": "object" + }, + { + "properties": { + "content_index": { + "default": 0, + "format": "int64", + "type": "integer" + }, + "delta": { + "type": "string" + }, + "item_id": { + "type": "string" + }, + "thread_id": { + "type": "string" + }, + "turn_id": { + "type": "string" + }, + "type": { + "enum": [ + "reasoning_raw_content_delta" + ], + "title": "ReasoningRawContentDeltaEventMsgType", + "type": "string" + } + }, + "required": [ + "delta", + "item_id", + "thread_id", + "turn_id", + "type" + ], + "title": "ReasoningRawContentDeltaEventMsg", + "type": "object" + }, + { + "description": "Collab interaction: agent spawn begin.", + "properties": { + "call_id": { + "description": "Identifier for the collab tool call.", + "type": "string" + }, + "prompt": { + "description": "Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the beginning.", + "type": "string" + }, + "sender_thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the sender." + }, + "type": { + "enum": [ + "collab_agent_spawn_begin" + ], + "title": "CollabAgentSpawnBeginEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "prompt", + "sender_thread_id", + "type" + ], + "title": "CollabAgentSpawnBeginEventMsg", + "type": "object" + }, + { + "description": "Collab interaction: agent spawn end.", + "properties": { + "call_id": { + "description": "Identifier for the collab tool call.", + "type": "string" + }, + "new_agent_nickname": { + "description": "Optional nickname assigned to the new agent.", + "type": [ + "string", + "null" + ] + }, + "new_agent_role": { + "description": "Optional role assigned to the new agent.", + "type": [ + "string", + "null" + ] + }, + "new_thread_id": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadId" + }, + { + "type": "null" + } + ], + "description": "Thread ID of the newly spawned agent, if it was created." + }, + "prompt": { + "description": "Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the beginning.", + "type": "string" + }, + "sender_thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the sender." + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/AgentStatus" + } + ], + "description": "Last known status of the new agent reported to the sender agent." + }, + "type": { + "enum": [ + "collab_agent_spawn_end" + ], + "title": "CollabAgentSpawnEndEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "prompt", + "sender_thread_id", + "status", + "type" + ], + "title": "CollabAgentSpawnEndEventMsg", + "type": "object" + }, + { + "description": "Collab interaction: agent interaction begin.", + "properties": { + "call_id": { + "description": "Identifier for the collab tool call.", + "type": "string" + }, + "prompt": { + "description": "Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning.", + "type": "string" + }, + "receiver_thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the receiver." + }, + "sender_thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the sender." + }, + "type": { + "enum": [ + "collab_agent_interaction_begin" + ], + "title": "CollabAgentInteractionBeginEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "prompt", + "receiver_thread_id", + "sender_thread_id", + "type" + ], + "title": "CollabAgentInteractionBeginEventMsg", + "type": "object" + }, + { + "description": "Collab interaction: agent interaction end.", + "properties": { + "call_id": { + "description": "Identifier for the collab tool call.", + "type": "string" + }, + "prompt": { + "description": "Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning.", + "type": "string" + }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the receiver." + }, + "sender_thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the sender." + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/AgentStatus" + } + ], + "description": "Last known status of the receiver agent reported to the sender agent." + }, + "type": { + "enum": [ + "collab_agent_interaction_end" + ], + "title": "CollabAgentInteractionEndEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "prompt", + "receiver_thread_id", + "sender_thread_id", + "status", + "type" + ], + "title": "CollabAgentInteractionEndEventMsg", + "type": "object" + }, + { + "description": "Collab interaction: waiting begin.", + "properties": { + "call_id": { + "description": "ID of the waiting call.", + "type": "string" + }, + "receiver_agents": { + "description": "Optional nicknames/roles for receivers.", + "items": { + "$ref": "#/definitions/CollabAgentRef" + }, + "type": "array" + }, + "receiver_thread_ids": { + "description": "Thread ID of the receivers.", + "items": { + "$ref": "#/definitions/ThreadId" + }, + "type": "array" + }, + "sender_thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the sender." + }, + "type": { + "enum": [ + "collab_waiting_begin" + ], + "title": "CollabWaitingBeginEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "receiver_thread_ids", + "sender_thread_id", + "type" + ], + "title": "CollabWaitingBeginEventMsg", + "type": "object" + }, + { + "description": "Collab interaction: waiting end.", + "properties": { + "agent_statuses": { + "description": "Optional receiver metadata paired with final statuses.", + "items": { + "$ref": "#/definitions/CollabAgentStatusEntry" + }, + "type": "array" + }, + "call_id": { + "description": "ID of the waiting call.", + "type": "string" + }, + "sender_thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the sender." + }, + "statuses": { + "additionalProperties": { + "$ref": "#/definitions/AgentStatus" + }, + "description": "Last known status of the receiver agents reported to the sender agent.", + "type": "object" + }, + "type": { + "enum": [ + "collab_waiting_end" + ], + "title": "CollabWaitingEndEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "sender_thread_id", + "statuses", + "type" + ], + "title": "CollabWaitingEndEventMsg", + "type": "object" + }, + { + "description": "Collab interaction: close begin.", + "properties": { + "call_id": { + "description": "Identifier for the collab tool call.", + "type": "string" + }, + "receiver_thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the receiver." + }, + "sender_thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the sender." + }, + "type": { + "enum": [ + "collab_close_begin" + ], + "title": "CollabCloseBeginEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "receiver_thread_id", + "sender_thread_id", + "type" + ], + "title": "CollabCloseBeginEventMsg", + "type": "object" + }, + { + "description": "Collab interaction: close end.", + "properties": { + "call_id": { + "description": "Identifier for the collab tool call.", + "type": "string" + }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the receiver." + }, + "sender_thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the sender." + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/AgentStatus" + } + ], + "description": "Last known status of the receiver agent reported to the sender agent before the close." + }, + "type": { + "enum": [ + "collab_close_end" + ], + "title": "CollabCloseEndEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "receiver_thread_id", + "sender_thread_id", + "status", + "type" + ], + "title": "CollabCloseEndEventMsg", + "type": "object" + }, + { + "description": "Collab interaction: resume begin.", + "properties": { + "call_id": { + "description": "Identifier for the collab tool call.", + "type": "string" + }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the receiver." + }, + "sender_thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the sender." + }, + "type": { + "enum": [ + "collab_resume_begin" + ], + "title": "CollabResumeBeginEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "receiver_thread_id", + "sender_thread_id", + "type" + ], + "title": "CollabResumeBeginEventMsg", + "type": "object" + }, + { + "description": "Collab interaction: resume end.", + "properties": { + "call_id": { + "description": "Identifier for the collab tool call.", + "type": "string" + }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the receiver." + }, + "sender_thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the sender." + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/AgentStatus" + } + ], + "description": "Last known status of the receiver agent reported to the sender agent after resume." + }, + "type": { + "enum": [ + "collab_resume_end" + ], + "title": "CollabResumeEndEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "receiver_thread_id", + "sender_thread_id", + "status", + "type" + ], + "title": "CollabResumeEndEventMsg", + "type": "object" + } + ], + "title": "EventMsg" + }, + "ExecCommandSource": { + "enum": [ + "agent", + "user_shell", + "unified_exec_startup", + "unified_exec_interaction" + ], + "type": "string" + }, + "ExecCommandStatus": { + "enum": [ + "completed", + "failed", + "declined" + ], + "type": "string" + }, + "ExecOutputStream": { + "enum": [ + "stdout", + "stderr" + ], + "type": "string" + }, + "ExperimentalFeature": { + "properties": { + "announcement": { + "description": "Announcement copy shown to users when the feature is introduced. Null when this feature is not in beta.", + "type": [ + "string", + "null" + ] + }, + "defaultEnabled": { + "description": "Whether this feature is enabled by default.", + "type": "boolean" + }, + "description": { + "description": "Short summary describing what the feature does. Null when this feature is not in beta.", + "type": [ + "string", + "null" + ] + }, + "displayName": { + "description": "User-facing display name shown in the experimental features UI. Null when this feature is not in beta.", + "type": [ + "string", + "null" + ] + }, + "enabled": { + "description": "Whether this feature is currently enabled in the loaded config.", + "type": "boolean" + }, + "name": { + "description": "Stable key used in config.toml and CLI flag toggles.", + "type": "string" + }, + "stage": { + "allOf": [ + { + "$ref": "#/definitions/ExperimentalFeatureStage" + } + ], + "description": "Lifecycle stage of this feature flag." + } + }, + "required": [ + "defaultEnabled", + "enabled", + "name", + "stage" + ], + "type": "object" + }, + "ExperimentalFeatureListParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cursor": { + "description": "Opaque pagination cursor returned by a previous call.", + "type": [ + "string", + "null" + ] + }, + "limit": { + "description": "Optional page size; defaults to a reasonable server-side value.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "title": "ExperimentalFeatureListParams", + "type": "object" + }, + "ExperimentalFeatureListResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/ExperimentalFeature" + }, + "type": "array" + }, + "nextCursor": { + "description": "Opaque cursor to pass to the next call to continue after the last item. If None, there are no more items to return.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "data" + ], + "title": "ExperimentalFeatureListResponse", + "type": "object" + }, + "ExperimentalFeatureStage": { + "oneOf": [ + { + "description": "Feature is available for user testing and feedback.", + "enum": [ + "beta" + ], + "type": "string" + }, + { + "description": "Feature is still being built and not ready for broad use.", + "enum": [ + "underDevelopment" + ], + "type": "string" + }, + { + "description": "Feature is production-ready.", + "enum": [ + "stable" + ], + "type": "string" + }, + { + "description": "Feature is deprecated and should be avoided.", + "enum": [ + "deprecated" + ], + "type": "string" + }, + { + "description": "Feature flag is retained only for backwards compatibility.", + "enum": [ + "removed" + ], + "type": "string" + } + ] + }, + "ExternalAgentConfigDetectParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cwds": { + "description": "Zero or more working directories to include for repo-scoped detection.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "includeHome": { + "description": "If true, include detection under the user's home (~/.claude, ~/.codex, etc.).", + "type": "boolean" + } + }, + "title": "ExternalAgentConfigDetectParams", + "type": "object" + }, + "ExternalAgentConfigDetectResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "items": { + "items": { + "$ref": "#/definitions/ExternalAgentConfigMigrationItem" + }, + "type": "array" + } + }, + "required": [ + "items" + ], + "title": "ExternalAgentConfigDetectResponse", + "type": "object" + }, + "ExternalAgentConfigImportParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "migrationItems": { + "items": { + "$ref": "#/definitions/ExternalAgentConfigMigrationItem" + }, + "type": "array" + } + }, + "required": [ + "migrationItems" + ], + "title": "ExternalAgentConfigImportParams", + "type": "object" + }, + "ExternalAgentConfigImportResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExternalAgentConfigImportResponse", + "type": "object" + }, + "ExternalAgentConfigMigrationItem": { + "properties": { + "cwd": { + "description": "Null or empty means home-scoped migration; non-empty means repo-scoped migration.", + "type": [ + "string", + "null" + ] + }, + "description": { + "type": "string" + }, + "itemType": { + "$ref": "#/definitions/ExternalAgentConfigMigrationItemType" + } + }, + "required": [ + "description", + "itemType" + ], + "type": "object" + }, + "ExternalAgentConfigMigrationItemType": { + "enum": [ + "AGENTS_MD", + "CONFIG", + "SKILLS", + "MCP_SERVER_CONFIG" + ], + "type": "string" + }, + "FeedbackUploadParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "classification": { + "type": "string" + }, + "extraLogFiles": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "includeLogs": { + "type": "boolean" + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "threadId": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "classification", + "includeLogs" + ], + "title": "FeedbackUploadParams", + "type": "object" + }, + "FeedbackUploadResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "FeedbackUploadResponse", + "type": "object" + }, + "FileChange": { + "oneOf": [ + { + "properties": { + "content": { + "type": "string" + }, + "type": { + "enum": [ + "add" + ], + "title": "AddFileChangeType", + "type": "string" + } + }, + "required": [ + "content", + "type" + ], + "title": "AddFileChange", + "type": "object" + }, + { + "properties": { + "content": { + "type": "string" + }, + "type": { + "enum": [ + "delete" + ], + "title": "DeleteFileChangeType", + "type": "string" + } + }, + "required": [ + "content", + "type" + ], + "title": "DeleteFileChange", + "type": "object" + }, + { + "properties": { + "move_path": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "update" + ], + "title": "UpdateFileChangeType", + "type": "string" + }, + "unified_diff": { + "type": "string" + } + }, + "required": [ + "type", + "unified_diff" + ], + "title": "UpdateFileChange", + "type": "object" + } + ] + }, + "FileChangeOutputDeltaNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "delta": { + "type": "string" + }, + "itemId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "delta", + "itemId", + "threadId", + "turnId" + ], + "title": "FileChangeOutputDeltaNotification", + "type": "object" + }, + "FileSystemPermissions": { + "properties": { + "read": { + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + }, + "write": { + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + } + }, + "type": "object" + }, + "FileUpdateChange": { + "properties": { + "diff": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/PatchChangeKind" + }, + "path": { + "type": "string" + } + }, + "required": [ + "diff", + "kind", + "path" + ], + "type": "object" + }, + "ForcedLoginMethod": { + "enum": [ + "chatgpt", + "api" + ], + "type": "string" + }, + "FunctionCallOutputBody": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "$ref": "#/definitions/FunctionCallOutputContentItem" + }, + "type": "array" + } + ] + }, + "FunctionCallOutputContentItem": { + "description": "Responses API compatible content items that can be returned by a tool call. This is a subset of ContentItem with the types we support as function call outputs.", + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "input_text" + ], + "title": "InputTextFunctionCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "InputTextFunctionCallOutputContentItem", + "type": "object" + }, + { + "properties": { + "image_url": { + "type": "string" + }, + "type": { + "enum": [ + "input_image" + ], + "title": "InputImageFunctionCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "image_url", + "type" + ], + "title": "InputImageFunctionCallOutputContentItem", + "type": "object" + } + ] + }, + "FunctionCallOutputPayload": { + "description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`body` serializes directly as the wire value for `function_call_output.output`. `success` remains internal metadata for downstream handling.", + "properties": { + "body": { + "$ref": "#/definitions/FunctionCallOutputBody" + }, + "success": { + "type": [ + "boolean", + "null" + ] + } + }, + "required": [ + "body" + ], + "type": "object" + }, + "FuzzyFileSearchParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cancellationToken": { + "type": [ + "string", + "null" + ] + }, + "query": { + "type": "string" + }, + "roots": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "query", + "roots" + ], + "title": "FuzzyFileSearchParams", + "type": "object" + }, + "FuzzyFileSearchResult": { + "description": "Superset of [`codex_file_search::FileMatch`]", + "properties": { + "file_name": { + "type": "string" + }, + "indices": { + "items": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "type": [ + "array", + "null" + ] + }, + "path": { + "type": "string" + }, + "root": { + "type": "string" + }, + "score": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "file_name", + "path", + "root", + "score" + ], + "type": "object" + }, + "FuzzyFileSearchSessionCompletedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "sessionId": { + "type": "string" + } + }, + "required": [ + "sessionId" + ], + "title": "FuzzyFileSearchSessionCompletedNotification", + "type": "object" + }, + "FuzzyFileSearchSessionUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "files": { + "items": { + "$ref": "#/definitions/FuzzyFileSearchResult" + }, + "type": "array" + }, + "query": { + "type": "string" + }, + "sessionId": { + "type": "string" + } + }, + "required": [ + "files", + "query", + "sessionId" + ], + "title": "FuzzyFileSearchSessionUpdatedNotification", + "type": "object" + }, + "GetAccountParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "refreshToken": { + "default": false, + "description": "When `true`, requests a proactive token refresh before returning.\n\nIn managed auth mode this triggers the normal refresh-token flow. In external auth mode this flag is ignored. Clients should refresh tokens themselves and call `account/login/start` with `chatgptAuthTokens`.", + "type": "boolean" + } + }, + "title": "GetAccountParams", + "type": "object" + }, + "GetAccountRateLimitsResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "rateLimits": { + "allOf": [ + { + "$ref": "#/definitions/RateLimitSnapshot" + } + ], + "description": "Backward-compatible single-bucket view; mirrors the historical payload." + }, + "rateLimitsByLimitId": { + "additionalProperties": { + "$ref": "#/definitions/RateLimitSnapshot" + }, + "description": "Multi-bucket view keyed by metered `limit_id` (for example, `codex`).", + "type": [ + "object", + "null" + ] + } + }, + "required": [ + "rateLimits" + ], + "title": "GetAccountRateLimitsResponse", + "type": "object" + }, + "GetAccountResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "account": { + "anyOf": [ + { + "$ref": "#/definitions/Account" + }, + { + "type": "null" + } + ] + }, + "requiresOpenaiAuth": { + "type": "boolean" + } + }, + "required": [ + "requiresOpenaiAuth" + ], + "title": "GetAccountResponse", + "type": "object" + }, + "GhostCommit": { + "description": "Details of a ghost commit created from a repository state.", + "properties": { + "id": { + "type": "string" + }, + "parent": { + "type": [ + "string", + "null" + ] + }, + "preexisting_untracked_dirs": { + "items": { + "type": "string" + }, + "type": "array" + }, + "preexisting_untracked_files": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "id", + "preexisting_untracked_dirs", + "preexisting_untracked_files" + ], + "type": "object" + }, + "GitInfo": { + "properties": { + "branch": { + "type": [ + "string", + "null" + ] + }, + "originUrl": { + "type": [ + "string", + "null" + ] + }, + "sha": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "HazelnutScope": { + "enum": [ + "example", + "workspace-shared", + "all-shared", + "personal" + ], + "type": "string" + }, + "HistoryEntry": { + "properties": { + "conversation_id": { + "type": "string" + }, + "text": { + "type": "string" + }, + "ts": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "conversation_id", + "text", + "ts" + ], + "type": "object" + }, + "InitializeCapabilities": { + "description": "Client-declared capabilities negotiated during initialize.", + "properties": { + "experimentalApi": { + "default": false, + "description": "Opt into receiving experimental API methods and fields.", + "type": "boolean" + }, + "optOutNotificationMethods": { + "description": "Exact notification method names that should be suppressed for this connection (for example `codex/event/session_configured`).", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + } + }, + "type": "object" + }, + "InitializeParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "capabilities": { + "anyOf": [ + { + "$ref": "#/definitions/InitializeCapabilities" + }, + { + "type": "null" + } + ] + }, + "clientInfo": { + "$ref": "#/definitions/ClientInfo" + } + }, + "required": [ + "clientInfo" + ], + "title": "InitializeParams", + "type": "object" + }, + "InputModality": { + "description": "Canonical user-input modality tags advertised by a model.", + "oneOf": [ + { + "description": "Plain text turns and tool payloads.", + "enum": [ + "text" + ], + "type": "string" + }, + { + "description": "Image attachments included in user turns.", + "enum": [ + "image" + ], + "type": "string" + } + ] + }, + "ItemCompletedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "item": { + "$ref": "#/definitions/ThreadItem" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "item", + "threadId", + "turnId" + ], + "title": "ItemCompletedNotification", + "type": "object" + }, + "ItemStartedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "item": { + "$ref": "#/definitions/ThreadItem" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "item", + "threadId", + "turnId" + ], + "title": "ItemStartedNotification", + "type": "object" + }, + "ListMcpServerStatusParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cursor": { + "description": "Opaque pagination cursor returned by a previous call.", + "type": [ + "string", + "null" + ] + }, + "limit": { + "description": "Optional page size; defaults to a server-defined value.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "title": "ListMcpServerStatusParams", + "type": "object" + }, + "ListMcpServerStatusResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/McpServerStatus" + }, + "type": "array" + }, + "nextCursor": { + "description": "Opaque cursor to pass to the next call to continue after the last item. If None, there are no more items to return.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "data" + ], + "title": "ListMcpServerStatusResponse", + "type": "object" + }, + "LocalShellAction": { + "oneOf": [ + { + "properties": { + "command": { + "items": { + "type": "string" + }, + "type": "array" + }, + "env": { + "additionalProperties": { + "type": "string" + }, + "type": [ + "object", + "null" + ] + }, + "timeout_ms": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "type": { + "enum": [ + "exec" + ], + "title": "ExecLocalShellActionType", + "type": "string" + }, + "user": { + "type": [ + "string", + "null" + ] + }, + "working_directory": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "command", + "type" + ], + "title": "ExecLocalShellAction", + "type": "object" + } + ] + }, + "LocalShellStatus": { + "enum": [ + "completed", + "in_progress", + "incomplete" + ], + "type": "string" + }, + "LoginAccountParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "oneOf": [ + { + "properties": { + "apiKey": { + "type": "string" + }, + "type": { + "enum": [ + "apiKey" + ], + "title": "ApiKeyv2::LoginAccountParamsType", + "type": "string" + } + }, + "required": [ + "apiKey", + "type" + ], + "title": "ApiKeyv2::LoginAccountParams", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "chatgpt" + ], + "title": "Chatgptv2::LoginAccountParamsType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "Chatgptv2::LoginAccountParams", + "type": "object" + }, + { + "description": "[UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE. The access token must contain the same scopes that Codex-managed ChatGPT auth tokens have.", + "properties": { + "accessToken": { + "description": "Access token (JWT) supplied by the client. This token is used for backend API requests and email extraction.", + "type": "string" + }, + "chatgptAccountId": { + "description": "Workspace/account identifier supplied by the client.", + "type": "string" + }, + "chatgptPlanType": { + "description": "Optional plan type supplied by the client.\n\nWhen `null`, Codex attempts to derive the plan type from access-token claims. If unavailable, the plan defaults to `unknown`.", + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "chatgptAuthTokens" + ], + "title": "ChatgptAuthTokensv2::LoginAccountParamsType", + "type": "string" + } + }, + "required": [ + "accessToken", + "chatgptAccountId", + "type" + ], + "title": "ChatgptAuthTokensv2::LoginAccountParams", + "type": "object" + } + ], + "title": "LoginAccountParams" + }, + "LoginAccountResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "apiKey" + ], + "title": "ApiKeyv2::LoginAccountResponseType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ApiKeyv2::LoginAccountResponse", + "type": "object" + }, + { + "properties": { + "authUrl": { + "description": "URL the client should open in a browser to initiate the OAuth flow.", + "type": "string" + }, + "loginId": { + "type": "string" + }, + "type": { + "enum": [ + "chatgpt" + ], + "title": "Chatgptv2::LoginAccountResponseType", + "type": "string" + } + }, + "required": [ + "authUrl", + "loginId", + "type" + ], + "title": "Chatgptv2::LoginAccountResponse", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "chatgptAuthTokens" + ], + "title": "ChatgptAuthTokensv2::LoginAccountResponseType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ChatgptAuthTokensv2::LoginAccountResponse", + "type": "object" + } + ], + "title": "LoginAccountResponse" + }, + "LogoutAccountResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "LogoutAccountResponse", + "type": "object" + }, + "MacOsAutomationValue": { + "anyOf": [ + { + "type": "boolean" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ] + }, + "MacOsPermissions": { + "properties": { + "accessibility": { + "type": [ + "boolean", + "null" + ] + }, + "automations": { + "anyOf": [ + { + "$ref": "#/definitions/MacOsAutomationValue" + }, + { + "type": "null" + } + ] + }, + "calendar": { + "type": [ + "boolean", + "null" + ] + }, + "preferences": { + "anyOf": [ + { + "$ref": "#/definitions/MacOsPreferencesValue" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "MacOsPreferencesValue": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + } + ] + }, + "McpAuthStatus": { + "enum": [ + "unsupported", + "notLoggedIn", + "bearerToken", + "oAuth" + ], + "type": "string" + }, + "McpInvocation": { + "properties": { + "arguments": { + "description": "Arguments to the tool call." + }, + "server": { + "description": "Name of the MCP server as defined in the config.", + "type": "string" + }, + "tool": { + "description": "Name of the tool as given by the MCP server.", + "type": "string" + } + }, + "required": [ + "server", + "tool" + ], + "type": "object" + }, + "McpServerOauthLoginCompletedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "error": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "success": { + "type": "boolean" + } + }, + "required": [ + "name", + "success" + ], + "title": "McpServerOauthLoginCompletedNotification", + "type": "object" + }, + "McpServerOauthLoginParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "name": { + "type": "string" + }, + "scopes": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "timeoutSecs": { + "format": "int64", + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "name" + ], + "title": "McpServerOauthLoginParams", + "type": "object" + }, + "McpServerOauthLoginResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "authorizationUrl": { + "type": "string" + } + }, + "required": [ + "authorizationUrl" + ], + "title": "McpServerOauthLoginResponse", + "type": "object" + }, + "McpServerRefreshResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "McpServerRefreshResponse", + "type": "object" + }, + "McpServerStatus": { + "properties": { + "authStatus": { + "$ref": "#/definitions/McpAuthStatus" + }, + "name": { + "type": "string" + }, + "resourceTemplates": { + "items": { + "$ref": "#/definitions/ResourceTemplate" + }, + "type": "array" + }, + "resources": { + "items": { + "$ref": "#/definitions/Resource" + }, + "type": "array" + }, + "tools": { + "additionalProperties": { + "$ref": "#/definitions/Tool" + }, + "type": "object" + } + }, + "required": [ + "authStatus", + "name", + "resourceTemplates", + "resources", + "tools" + ], + "type": "object" + }, + "McpStartupFailure": { + "properties": { + "error": { + "type": "string" + }, + "server": { + "type": "string" + } + }, + "required": [ + "error", + "server" + ], + "type": "object" + }, + "McpStartupStatus": { + "oneOf": [ + { + "properties": { + "state": { + "enum": [ + "starting" + ], + "type": "string" + } + }, + "required": [ + "state" + ], + "title": "StartingMcpStartupStatus", + "type": "object" + }, + { + "properties": { + "state": { + "enum": [ + "ready" + ], + "type": "string" + } + }, + "required": [ + "state" + ], + "title": "ReadyMcpStartupStatus", + "type": "object" + }, + { + "properties": { + "error": { + "type": "string" + }, + "state": { + "enum": [ + "failed" + ], + "type": "string" + } + }, + "required": [ + "error", + "state" + ], + "type": "object" + }, + { + "properties": { + "state": { + "enum": [ + "cancelled" + ], + "type": "string" + } + }, + "required": [ + "state" + ], + "title": "CancelledMcpStartupStatus", + "type": "object" + } + ] + }, + "McpToolCallError": { + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + }, + "McpToolCallProgressNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "itemId": { + "type": "string" + }, + "message": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "itemId", + "message", + "threadId", + "turnId" + ], + "title": "McpToolCallProgressNotification", + "type": "object" + }, + "McpToolCallResult": { + "properties": { + "content": { + "items": true, + "type": "array" + }, + "structuredContent": true + }, + "required": [ + "content" + ], + "type": "object" + }, + "McpToolCallStatus": { + "enum": [ + "inProgress", + "completed", + "failed" + ], + "type": "string" + }, + "MergeStrategy": { + "enum": [ + "replace", + "upsert" + ], + "type": "string" + }, + "MessagePhase": { + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "enum": [ + "commentary" + ], + "type": "string" + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "enum": [ + "final_answer" + ], + "type": "string" + } + ] + }, + "ModeKind": { + "description": "Initial collaboration mode to use when the TUI starts.", + "enum": [ + "plan", + "default" + ], + "type": "string" + }, + "Model": { + "properties": { + "availabilityNux": { + "anyOf": [ + { + "$ref": "#/definitions/ModelAvailabilityNux" + }, + { + "type": "null" + } + ] + }, + "defaultReasoningEffort": { + "$ref": "#/definitions/ReasoningEffort" + }, + "description": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "hidden": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "inputModalities": { + "default": [ + "text", + "image" + ], + "items": { + "$ref": "#/definitions/InputModality" + }, + "type": "array" + }, + "isDefault": { + "type": "boolean" + }, + "model": { + "type": "string" + }, + "supportedReasoningEfforts": { + "items": { + "$ref": "#/definitions/ReasoningEffortOption" + }, + "type": "array" + }, + "supportsPersonality": { + "default": false, + "type": "boolean" + }, + "upgrade": { + "type": [ + "string", + "null" + ] + }, + "upgradeInfo": { + "anyOf": [ + { + "$ref": "#/definitions/ModelUpgradeInfo" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "defaultReasoningEffort", + "description", + "displayName", + "hidden", + "id", + "isDefault", + "model", + "supportedReasoningEfforts" + ], + "type": "object" + }, + "ModelAvailabilityNux": { + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + }, + "ModelListParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cursor": { + "description": "Opaque pagination cursor returned by a previous call.", + "type": [ + "string", + "null" + ] + }, + "includeHidden": { + "description": "When true, include models that are hidden from the default picker list.", + "type": [ + "boolean", + "null" + ] + }, + "limit": { + "description": "Optional page size; defaults to a reasonable server-side value.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "title": "ModelListParams", + "type": "object" + }, + "ModelListResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/Model" + }, + "type": "array" + }, + "nextCursor": { + "description": "Opaque cursor to pass to the next call to continue after the last item. If None, there are no more items to return.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "data" + ], + "title": "ModelListResponse", + "type": "object" + }, + "ModelRerouteReason": { + "enum": [ + "highRiskCyberActivity" + ], + "type": "string" + }, + "ModelReroutedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "fromModel": { + "type": "string" + }, + "reason": { + "$ref": "#/definitions/ModelRerouteReason" + }, + "threadId": { + "type": "string" + }, + "toModel": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "fromModel", + "reason", + "threadId", + "toModel", + "turnId" + ], + "title": "ModelReroutedNotification", + "type": "object" + }, + "ModelUpgradeInfo": { + "properties": { + "migrationMarkdown": { + "type": [ + "string", + "null" + ] + }, + "model": { + "type": "string" + }, + "modelLink": { + "type": [ + "string", + "null" + ] + }, + "upgradeCopy": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "model" + ], + "type": "object" + }, + "NetworkAccess": { + "enum": [ + "restricted", + "enabled" + ], + "type": "string" + }, + "NetworkApprovalContext": { + "properties": { + "host": { + "type": "string" + }, + "protocol": { + "$ref": "#/definitions/NetworkApprovalProtocol" + } + }, + "required": [ + "host", + "protocol" + ], + "type": "object" + }, + "NetworkApprovalProtocol": { + "enum": [ + "http", + "https", + "socks5Tcp", + "socks5Udp" + ], + "type": "string" + }, + "NetworkPolicyAmendment": { + "properties": { + "action": { + "$ref": "#/definitions/NetworkPolicyRuleAction" + }, + "host": { + "type": "string" + } + }, + "required": [ + "action", + "host" + ], + "type": "object" + }, + "NetworkPolicyRuleAction": { + "enum": [ + "allow", + "deny" + ], + "type": "string" + }, + "NetworkRequirements": { + "properties": { + "allowLocalBinding": { + "type": [ + "boolean", + "null" + ] + }, + "allowUnixSockets": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "allowUpstreamProxy": { + "type": [ + "boolean", + "null" + ] + }, + "allowedDomains": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "dangerouslyAllowAllUnixSockets": { + "type": [ + "boolean", + "null" + ] + }, + "dangerouslyAllowNonLoopbackAdmin": { + "type": [ + "boolean", + "null" + ] + }, + "dangerouslyAllowNonLoopbackProxy": { + "type": [ + "boolean", + "null" + ] + }, + "deniedDomains": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "enabled": { + "type": [ + "boolean", + "null" + ] + }, + "httpPort": { + "format": "uint16", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "socksPort": { + "format": "uint16", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, + "OverriddenMetadata": { + "properties": { + "effectiveValue": true, + "message": { + "type": "string" + }, + "overridingLayer": { + "$ref": "#/definitions/ConfigLayerMetadata" + } + }, + "required": [ + "effectiveValue", + "message", + "overridingLayer" + ], + "type": "object" + }, + "ParsedCommand": { + "oneOf": [ + { + "properties": { + "cmd": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "description": "(Best effort) Path to the file being read by the command. When possible, this is an absolute path, though when relative, it should be resolved against the `cwd`` that will be used to run the command to derive the absolute path.", + "type": "string" + }, + "type": { + "enum": [ + "read" + ], + "title": "ReadParsedCommandType", + "type": "string" + } + }, + "required": [ + "cmd", + "name", + "path", + "type" + ], + "title": "ReadParsedCommand", + "type": "object" + }, + { + "properties": { + "cmd": { + "type": "string" + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "list_files" + ], + "title": "ListFilesParsedCommandType", + "type": "string" + } + }, + "required": [ + "cmd", + "type" + ], + "title": "ListFilesParsedCommand", + "type": "object" + }, + { + "properties": { + "cmd": { + "type": "string" + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "search" + ], + "title": "SearchParsedCommandType", + "type": "string" + } + }, + "required": [ + "cmd", + "type" + ], + "title": "SearchParsedCommand", + "type": "object" + }, + { + "properties": { + "cmd": { + "type": "string" + }, + "type": { + "enum": [ + "unknown" + ], + "title": "UnknownParsedCommandType", + "type": "string" + } + }, + "required": [ + "cmd", + "type" + ], + "title": "UnknownParsedCommand", + "type": "object" + } + ] + }, + "PatchApplyStatus": { + "enum": [ + "inProgress", + "completed", + "failed", + "declined" + ], + "type": "string" + }, + "PatchChangeKind": { + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "add" + ], + "title": "AddPatchChangeKindType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "AddPatchChangeKind", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "delete" + ], + "title": "DeletePatchChangeKindType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "DeletePatchChangeKind", + "type": "object" + }, + { + "properties": { + "move_path": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "update" + ], + "title": "UpdatePatchChangeKindType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "UpdatePatchChangeKind", + "type": "object" + } + ] + }, + "PermissionProfile": { + "properties": { + "file_system": { + "anyOf": [ + { + "$ref": "#/definitions/FileSystemPermissions" + }, + { + "type": "null" + } + ] + }, + "macos": { + "anyOf": [ + { + "$ref": "#/definitions/MacOsPermissions" + }, + { + "type": "null" + } + ] + }, + "network": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, + "Personality": { + "enum": [ + "none", + "friendly", + "pragmatic" + ], + "type": "string" + }, + "PlanDeltaNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - proposed plan streaming deltas for plan items. Clients should not assume concatenated deltas match the completed plan item content.", + "properties": { + "delta": { + "type": "string" + }, + "itemId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "delta", + "itemId", + "threadId", + "turnId" + ], + "title": "PlanDeltaNotification", + "type": "object" + }, + "PlanItemArg": { + "additionalProperties": false, + "properties": { + "status": { + "$ref": "#/definitions/StepStatus" + }, + "step": { + "type": "string" + } + }, + "required": [ + "status", + "step" + ], + "type": "object" + }, + "PlanType": { + "enum": [ + "free", + "go", + "plus", + "pro", + "team", + "business", + "enterprise", + "edu", + "unknown" + ], + "type": "string" + }, + "ProductSurface": { + "enum": [ + "chatgpt", + "codex", + "api", + "atlas" + ], + "type": "string" + }, + "ProfileV2": { + "additionalProperties": true, + "properties": { + "approval_policy": { + "anyOf": [ + { + "$ref": "#/definitions/AskForApproval" + }, + { + "type": "null" + } + ] + }, + "chatgpt_base_url": { + "type": [ + "string", + "null" + ] + }, + "model": { + "type": [ + "string", + "null" + ] + }, + "model_provider": { + "type": [ + "string", + "null" + ] + }, + "model_reasoning_effort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "model_reasoning_summary": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningSummary" + }, + { + "type": "null" + } + ] + }, + "model_verbosity": { + "anyOf": [ + { + "$ref": "#/definitions/Verbosity" + }, + { + "type": "null" + } + ] + }, + "web_search": { + "anyOf": [ + { + "$ref": "#/definitions/WebSearchMode" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "RateLimitSnapshot": { + "properties": { + "credits": { + "anyOf": [ + { + "$ref": "#/definitions/CreditsSnapshot" + }, + { + "type": "null" + } + ] + }, + "limitId": { + "type": [ + "string", + "null" + ] + }, + "limitName": { + "type": [ + "string", + "null" + ] + }, + "planType": { + "anyOf": [ + { + "$ref": "#/definitions/PlanType" + }, + { + "type": "null" + } + ] + }, + "primary": { + "anyOf": [ + { + "$ref": "#/definitions/RateLimitWindow" + }, + { + "type": "null" + } + ] + }, + "secondary": { + "anyOf": [ + { + "$ref": "#/definitions/RateLimitWindow" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "RateLimitWindow": { + "properties": { + "resetsAt": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "usedPercent": { + "format": "int32", + "type": "integer" + }, + "windowDurationMins": { + "format": "int64", + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "usedPercent" + ], + "type": "object" + }, + "RawResponseItemCompletedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "item": { + "$ref": "#/definitions/ResponseItem" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "item", + "threadId", + "turnId" + ], + "title": "RawResponseItemCompletedNotification", + "type": "object" + }, + "ReadOnlyAccess": { + "oneOf": [ + { + "properties": { + "includePlatformDefaults": { + "default": true, + "type": "boolean" + }, + "readableRoots": { + "default": [], + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "type": { + "enum": [ + "restricted" + ], + "title": "RestrictedReadOnlyAccessType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RestrictedReadOnlyAccess", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "fullAccess" + ], + "title": "FullAccessReadOnlyAccessType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "FullAccessReadOnlyAccess", + "type": "object" + } + ] + }, + "RealtimeAudioFrame": { + "properties": { + "data": { + "type": "string" + }, + "num_channels": { + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "sample_rate": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "samples_per_channel": { + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "data", + "num_channels", + "sample_rate" + ], + "type": "object" + }, + "RealtimeEvent": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "SessionUpdated": { + "properties": { + "instructions": { + "type": [ + "string", + "null" + ] + }, + "session_id": { + "type": "string" + } + }, + "required": [ + "session_id" + ], + "type": "object" + } + }, + "required": [ + "SessionUpdated" + ], + "title": "SessionUpdatedRealtimeEvent", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "AudioOut": { + "$ref": "#/definitions/RealtimeAudioFrame" + } + }, + "required": [ + "AudioOut" + ], + "title": "AudioOutRealtimeEvent", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "ConversationItemAdded": true + }, + "required": [ + "ConversationItemAdded" + ], + "title": "ConversationItemAddedRealtimeEvent", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "ConversationItemDone": { + "properties": { + "item_id": { + "type": "string" + } + }, + "required": [ + "item_id" + ], + "type": "object" + } + }, + "required": [ + "ConversationItemDone" + ], + "title": "ConversationItemDoneRealtimeEvent", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "HandoffRequested": { + "$ref": "#/definitions/RealtimeHandoffRequested" + } + }, + "required": [ + "HandoffRequested" + ], + "title": "HandoffRequestedRealtimeEvent", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "Error": { + "type": "string" + } + }, + "required": [ + "Error" + ], + "title": "ErrorRealtimeEvent", + "type": "object" + } + ] + }, + "RealtimeHandoffMessage": { + "properties": { + "role": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "role", + "text" + ], + "type": "object" + }, + "RealtimeHandoffRequested": { + "properties": { + "handoff_id": { + "type": "string" + }, + "input_transcript": { + "type": "string" + }, + "item_id": { + "type": "string" + }, + "messages": { + "items": { + "$ref": "#/definitions/RealtimeHandoffMessage" + }, + "type": "array" + } + }, + "required": [ + "handoff_id", + "input_transcript", + "item_id", + "messages" + ], + "type": "object" + }, + "ReasoningEffort": { + "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "type": "string" + }, + "ReasoningEffortOption": { + "properties": { + "description": { + "type": "string" + }, + "reasoningEffort": { + "$ref": "#/definitions/ReasoningEffort" + } + }, + "required": [ + "description", + "reasoningEffort" + ], + "type": "object" + }, + "ReasoningItemContent": { + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "reasoning_text" + ], + "title": "ReasoningTextReasoningItemContentType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "ReasoningTextReasoningItemContent", + "type": "object" + }, + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "text" + ], + "title": "TextReasoningItemContentType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "TextReasoningItemContent", + "type": "object" + } + ] + }, + "ReasoningItemReasoningSummary": { + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "summary_text" + ], + "title": "SummaryTextReasoningItemReasoningSummaryType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "SummaryTextReasoningItemReasoningSummary", + "type": "object" + } + ] + }, + "ReasoningSummary": { + "description": "A summary of the reasoning performed by the model. This can be useful for debugging and understanding the model's reasoning process. See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries", + "oneOf": [ + { + "enum": [ + "auto", + "concise", + "detailed" + ], + "type": "string" + }, + { + "description": "Option to disable reasoning summaries.", + "enum": [ + "none" + ], + "type": "string" + } + ] + }, + "ReasoningSummaryPartAddedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "itemId": { + "type": "string" + }, + "summaryIndex": { + "format": "int64", + "type": "integer" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "itemId", + "summaryIndex", + "threadId", + "turnId" + ], + "title": "ReasoningSummaryPartAddedNotification", + "type": "object" + }, + "ReasoningSummaryTextDeltaNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "delta": { + "type": "string" + }, + "itemId": { + "type": "string" + }, + "summaryIndex": { + "format": "int64", + "type": "integer" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "delta", + "itemId", + "summaryIndex", + "threadId", + "turnId" + ], + "title": "ReasoningSummaryTextDeltaNotification", + "type": "object" + }, + "ReasoningTextDeltaNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "contentIndex": { + "format": "int64", + "type": "integer" + }, + "delta": { + "type": "string" + }, + "itemId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "contentIndex", + "delta", + "itemId", + "threadId", + "turnId" + ], + "title": "ReasoningTextDeltaNotification", + "type": "object" + }, + "RemoteSkillSummary": { + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "description", + "id", + "name" + ], + "type": "object" + }, + "RequestId": { + "anyOf": [ + { + "type": "string" + }, + { + "format": "int64", + "type": "integer" + } + ] + }, + "RequestUserInputQuestion": { + "properties": { + "header": { + "type": "string" + }, + "id": { + "type": "string" + }, + "isOther": { + "default": false, + "type": "boolean" + }, + "isSecret": { + "default": false, + "type": "boolean" + }, + "options": { + "items": { + "$ref": "#/definitions/RequestUserInputQuestionOption" + }, + "type": [ + "array", + "null" + ] + }, + "question": { + "type": "string" + } + }, + "required": [ + "header", + "id", + "question" + ], + "type": "object" + }, + "RequestUserInputQuestionOption": { + "properties": { + "description": { + "type": "string" + }, + "label": { + "type": "string" + } + }, + "required": [ + "description", + "label" + ], + "type": "object" + }, + "ResidencyRequirement": { + "enum": [ + "us" + ], + "type": "string" + }, + "Resource": { + "description": "A known resource that the server is capable of reading.", + "properties": { + "_meta": true, + "annotations": true, + "description": { + "type": [ + "string", + "null" + ] + }, + "icons": { + "items": true, + "type": [ + "array", + "null" + ] + }, + "mimeType": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "size": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "uri": { + "type": "string" + } + }, + "required": [ + "name", + "uri" + ], + "type": "object" + }, + "ResourceTemplate": { + "description": "A template description for resources available on the server.", + "properties": { + "annotations": true, + "description": { + "type": [ + "string", + "null" + ] + }, + "mimeType": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "uriTemplate": { + "type": "string" + } + }, + "required": [ + "name", + "uriTemplate" + ], + "type": "object" + }, + "ResponseItem": { + "oneOf": [ + { + "properties": { + "content": { + "items": { + "$ref": "#/definitions/ContentItem" + }, + "type": "array" + }, + "end_turn": { + "type": [ + "boolean", + "null" + ] + }, + "id": { + "type": [ + "string", + "null" + ], + "writeOnly": true + }, + "phase": { + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase" + }, + { + "type": "null" + } + ] + }, + "role": { + "type": "string" + }, + "type": { + "enum": [ + "message" + ], + "title": "MessageResponseItemType", + "type": "string" + } + }, + "required": [ + "content", + "role", + "type" + ], + "title": "MessageResponseItem", + "type": "object" + }, + { + "properties": { + "content": { + "default": null, + "items": { + "$ref": "#/definitions/ReasoningItemContent" + }, + "type": [ + "array", + "null" + ] + }, + "encrypted_content": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string", + "writeOnly": true + }, + "summary": { + "items": { + "$ref": "#/definitions/ReasoningItemReasoningSummary" + }, + "type": "array" + }, + "type": { + "enum": [ + "reasoning" + ], + "title": "ReasoningResponseItemType", + "type": "string" + } + }, + "required": [ + "id", + "summary", + "type" + ], + "title": "ReasoningResponseItem", + "type": "object" + }, + { + "properties": { + "action": { + "$ref": "#/definitions/LocalShellAction" + }, + "call_id": { + "description": "Set when using the Responses API.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Legacy id field retained for compatibility with older payloads.", + "type": [ + "string", + "null" + ], + "writeOnly": true + }, + "status": { + "$ref": "#/definitions/LocalShellStatus" + }, + "type": { + "enum": [ + "local_shell_call" + ], + "title": "LocalShellCallResponseItemType", + "type": "string" + } + }, + "required": [ + "action", + "status", + "type" + ], + "title": "LocalShellCallResponseItem", + "type": "object" + }, + { + "properties": { + "arguments": { + "type": "string" + }, + "call_id": { + "type": "string" + }, + "id": { + "type": [ + "string", + "null" + ], + "writeOnly": true + }, + "name": { + "type": "string" + }, + "type": { + "enum": [ + "function_call" + ], + "title": "FunctionCallResponseItemType", + "type": "string" + } + }, + "required": [ + "arguments", + "call_id", + "name", + "type" + ], + "title": "FunctionCallResponseItem", + "type": "object" + }, + { + "properties": { + "call_id": { + "type": "string" + }, + "output": { + "$ref": "#/definitions/FunctionCallOutputPayload" + }, + "type": { + "enum": [ + "function_call_output" + ], + "title": "FunctionCallOutputResponseItemType", + "type": "string" + } + }, + "required": [ + "call_id", + "output", + "type" + ], + "title": "FunctionCallOutputResponseItem", + "type": "object" + }, + { + "properties": { + "call_id": { + "type": "string" + }, + "id": { + "type": [ + "string", + "null" + ], + "writeOnly": true + }, + "input": { + "type": "string" + }, + "name": { + "type": "string" + }, + "status": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "custom_tool_call" + ], + "title": "CustomToolCallResponseItemType", + "type": "string" + } + }, + "required": [ + "call_id", + "input", + "name", + "type" + ], + "title": "CustomToolCallResponseItem", + "type": "object" + }, + { + "properties": { + "call_id": { + "type": "string" + }, + "output": { + "$ref": "#/definitions/FunctionCallOutputPayload" + }, + "type": { + "enum": [ + "custom_tool_call_output" + ], + "title": "CustomToolCallOutputResponseItemType", + "type": "string" + } + }, + "required": [ + "call_id", + "output", + "type" + ], + "title": "CustomToolCallOutputResponseItem", + "type": "object" + }, + { + "properties": { + "action": { + "anyOf": [ + { + "$ref": "#/definitions/WebSearchAction" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": [ + "string", + "null" + ], + "writeOnly": true + }, + "status": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "web_search_call" + ], + "title": "WebSearchCallResponseItemType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "WebSearchCallResponseItem", + "type": "object" + }, + { + "properties": { + "ghost_commit": { + "$ref": "#/definitions/GhostCommit" + }, + "type": { + "enum": [ + "ghost_snapshot" + ], + "title": "GhostSnapshotResponseItemType", + "type": "string" + } + }, + "required": [ + "ghost_commit", + "type" + ], + "title": "GhostSnapshotResponseItem", + "type": "object" + }, + { + "properties": { + "encrypted_content": { + "type": "string" + }, + "type": { + "enum": [ + "compaction" + ], + "title": "CompactionResponseItemType", + "type": "string" + } + }, + "required": [ + "encrypted_content", + "type" + ], + "title": "CompactionResponseItem", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "other" + ], + "title": "OtherResponseItemType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "OtherResponseItem", + "type": "object" + } + ] + }, + "Result_of_CallToolResult_or_String": { + "oneOf": [ + { + "properties": { + "Ok": { + "$ref": "#/definitions/CallToolResult" + } + }, + "required": [ + "Ok" + ], + "title": "OkResult_of_CallToolResult_or_String", + "type": "object" + }, + { + "properties": { + "Err": { + "type": "string" + } + }, + "required": [ + "Err" + ], + "title": "ErrResult_of_CallToolResult_or_String", + "type": "object" + } + ] + }, + "ReviewCodeLocation": { + "description": "Location of the code related to a review finding.", + "properties": { + "absolute_file_path": { + "type": "string" + }, + "line_range": { + "$ref": "#/definitions/ReviewLineRange" + } + }, + "required": [ + "absolute_file_path", + "line_range" + ], + "type": "object" + }, + "ReviewDecision": { + "description": "User's decision in response to an ExecApprovalRequest.", + "oneOf": [ + { + "description": "User has approved this command and the agent should execute it.", + "enum": [ + "approved" + ], + "type": "string" + }, + { + "additionalProperties": false, + "description": "User has approved this command and wants to apply the proposed execpolicy amendment so future matching commands are permitted.", + "properties": { + "approved_execpolicy_amendment": { + "properties": { + "proposed_execpolicy_amendment": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "proposed_execpolicy_amendment" + ], + "type": "object" + } + }, + "required": [ + "approved_execpolicy_amendment" + ], + "title": "ApprovedExecpolicyAmendmentReviewDecision", + "type": "object" + }, + { + "description": "User has approved this request and wants future prompts in the same session-scoped approval cache to be automatically approved for the remainder of the session.", + "enum": [ + "approved_for_session" + ], + "type": "string" + }, + { + "additionalProperties": false, + "description": "User chose to persist a network policy rule (allow/deny) for future requests to the same host.", + "properties": { + "network_policy_amendment": { + "properties": { + "network_policy_amendment": { + "$ref": "#/definitions/NetworkPolicyAmendment" + } + }, + "required": [ + "network_policy_amendment" + ], + "type": "object" + } + }, + "required": [ + "network_policy_amendment" + ], + "title": "NetworkPolicyAmendmentReviewDecision", + "type": "object" + }, + { + "description": "User has denied this command and the agent should not execute it, but it should continue the session and try something else.", + "enum": [ + "denied" + ], + "type": "string" + }, + { + "description": "User has denied this command and the agent should not do anything until the user's next command.", + "enum": [ + "abort" + ], + "type": "string" + } + ] + }, + "ReviewDelivery": { + "enum": [ + "inline", + "detached" + ], + "type": "string" + }, + "ReviewFinding": { + "description": "A single review finding describing an observed issue or recommendation.", + "properties": { + "body": { + "type": "string" + }, + "code_location": { + "$ref": "#/definitions/ReviewCodeLocation" + }, + "confidence_score": { + "format": "float", + "type": "number" + }, + "priority": { + "format": "int32", + "type": "integer" + }, + "title": { + "type": "string" + } + }, + "required": [ + "body", + "code_location", + "confidence_score", + "priority", + "title" + ], + "type": "object" + }, + "ReviewLineRange": { + "description": "Inclusive line range in a file associated with the finding.", + "properties": { + "end": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "start": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "end", + "start" + ], + "type": "object" + }, + "ReviewOutputEvent": { + "description": "Structured review result produced by a child review session.", + "properties": { + "findings": { + "items": { + "$ref": "#/definitions/ReviewFinding" + }, + "type": "array" + }, + "overall_confidence_score": { + "format": "float", + "type": "number" + }, + "overall_correctness": { + "type": "string" + }, + "overall_explanation": { + "type": "string" + } + }, + "required": [ + "findings", + "overall_confidence_score", + "overall_correctness", + "overall_explanation" + ], + "type": "object" + }, + "ReviewStartParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "delivery": { + "anyOf": [ + { + "$ref": "#/definitions/ReviewDelivery" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Where to run the review: inline (default) on the current thread or detached on a new thread (returned in `reviewThreadId`)." + }, + "target": { + "$ref": "#/definitions/ReviewTarget" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "target", + "threadId" + ], + "title": "ReviewStartParams", + "type": "object" + }, + "ReviewStartResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "reviewThreadId": { + "description": "Identifies the thread where the review runs.\n\nFor inline reviews, this is the original thread id. For detached reviews, this is the id of the new review thread.", + "type": "string" + }, + "turn": { + "$ref": "#/definitions/Turn" + } + }, + "required": [ + "reviewThreadId", + "turn" + ], + "title": "ReviewStartResponse", + "type": "object" + }, + "ReviewTarget": { + "oneOf": [ + { + "description": "Review the working tree: staged, unstaged, and untracked files.", + "properties": { + "type": { + "enum": [ + "uncommittedChanges" + ], + "title": "UncommittedChangesReviewTargetType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "UncommittedChangesReviewTarget", + "type": "object" + }, + { + "description": "Review changes between the current branch and the given base branch.", + "properties": { + "branch": { + "type": "string" + }, + "type": { + "enum": [ + "baseBranch" + ], + "title": "BaseBranchReviewTargetType", + "type": "string" + } + }, + "required": [ + "branch", + "type" + ], + "title": "BaseBranchReviewTarget", + "type": "object" + }, + { + "description": "Review the changes introduced by a specific commit.", + "properties": { + "sha": { + "type": "string" + }, + "title": { + "description": "Optional human-readable label (e.g., commit subject) for UIs.", + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "commit" + ], + "title": "CommitReviewTargetType", + "type": "string" + } + }, + "required": [ + "sha", + "type" + ], + "title": "CommitReviewTarget", + "type": "object" + }, + { + "description": "Arbitrary instructions, equivalent to the old free-form prompt.", + "properties": { + "instructions": { + "type": "string" + }, + "type": { + "enum": [ + "custom" + ], + "title": "CustomReviewTargetType", + "type": "string" + } + }, + "required": [ + "instructions", + "type" + ], + "title": "CustomReviewTarget", + "type": "object" + } + ] + }, + "SandboxMode": { + "enum": [ + "read-only", + "workspace-write", + "danger-full-access" + ], + "type": "string" + }, + "SandboxPolicy": { + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "dangerFullAccess" + ], + "title": "DangerFullAccessSandboxPolicyType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "DangerFullAccessSandboxPolicy", + "type": "object" + }, + { + "properties": { + "access": { + "allOf": [ + { + "$ref": "#/definitions/ReadOnlyAccess" + } + ], + "default": { + "type": "fullAccess" + } + }, + "type": { + "enum": [ + "readOnly" + ], + "title": "ReadOnlySandboxPolicyType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ReadOnlySandboxPolicy", + "type": "object" + }, + { + "properties": { + "networkAccess": { + "allOf": [ + { + "$ref": "#/definitions/NetworkAccess" + } + ], + "default": "restricted" + }, + "type": { + "enum": [ + "externalSandbox" + ], + "title": "ExternalSandboxSandboxPolicyType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ExternalSandboxSandboxPolicy", + "type": "object" + }, + { + "properties": { + "excludeSlashTmp": { + "default": false, + "type": "boolean" + }, + "excludeTmpdirEnvVar": { + "default": false, + "type": "boolean" + }, + "networkAccess": { + "default": false, + "type": "boolean" + }, + "readOnlyAccess": { + "allOf": [ + { + "$ref": "#/definitions/ReadOnlyAccess" + } + ], + "default": { + "type": "fullAccess" + } + }, + "type": { + "enum": [ + "workspaceWrite" + ], + "title": "WorkspaceWriteSandboxPolicyType", + "type": "string" + }, + "writableRoots": { + "default": [], + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + } + }, + "required": [ + "type" + ], + "title": "WorkspaceWriteSandboxPolicy", + "type": "object" + } + ] + }, + "SandboxWorkspaceWrite": { + "properties": { + "exclude_slash_tmp": { + "default": false, + "type": "boolean" + }, + "exclude_tmpdir_env_var": { + "default": false, + "type": "boolean" + }, + "network_access": { + "default": false, + "type": "boolean" + }, + "writable_roots": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "ServerNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Notification sent from the server to the client.", + "oneOf": [ + { + "description": "NEW NOTIFICATIONS", + "properties": { + "method": { + "enum": [ + "error" + ], + "title": "ErrorNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ErrorNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "ErrorNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/started" + ], + "title": "Thread/startedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadStartedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/startedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/status/changed" + ], + "title": "Thread/status/changedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadStatusChangedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/status/changedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/archived" + ], + "title": "Thread/archivedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadArchivedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/archivedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/unarchived" + ], + "title": "Thread/unarchivedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadUnarchivedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/unarchivedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/closed" + ], + "title": "Thread/closedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadClosedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/closedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/name/updated" + ], + "title": "Thread/name/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadNameUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/name/updatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/tokenUsage/updated" + ], + "title": "Thread/tokenUsage/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadTokenUsageUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/tokenUsage/updatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "turn/started" + ], + "title": "Turn/startedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/TurnStartedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Turn/startedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "turn/completed" + ], + "title": "Turn/completedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/TurnCompletedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Turn/completedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "turn/diff/updated" + ], + "title": "Turn/diff/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/TurnDiffUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Turn/diff/updatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "turn/plan/updated" + ], + "title": "Turn/plan/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/TurnPlanUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Turn/plan/updatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "item/started" + ], + "title": "Item/startedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ItemStartedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/startedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "item/completed" + ], + "title": "Item/completedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ItemCompletedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/completedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "item/agentMessage/delta" + ], + "title": "Item/agentMessage/deltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/AgentMessageDeltaNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/agentMessage/deltaNotification", + "type": "object" + }, + { + "description": "EXPERIMENTAL - proposed plan streaming deltas for plan items.", + "properties": { + "method": { + "enum": [ + "item/plan/delta" + ], + "title": "Item/plan/deltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PlanDeltaNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/plan/deltaNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "item/commandExecution/outputDelta" + ], + "title": "Item/commandExecution/outputDeltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/CommandExecutionOutputDeltaNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/commandExecution/outputDeltaNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "item/commandExecution/terminalInteraction" + ], + "title": "Item/commandExecution/terminalInteractionNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/TerminalInteractionNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/commandExecution/terminalInteractionNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "item/fileChange/outputDelta" + ], + "title": "Item/fileChange/outputDeltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FileChangeOutputDeltaNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/fileChange/outputDeltaNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "serverRequest/resolved" + ], + "title": "ServerRequest/resolvedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ServerRequestResolvedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "ServerRequest/resolvedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "item/mcpToolCall/progress" + ], + "title": "Item/mcpToolCall/progressNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/McpToolCallProgressNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/mcpToolCall/progressNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "mcpServer/oauthLogin/completed" + ], + "title": "McpServer/oauthLogin/completedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/McpServerOauthLoginCompletedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "McpServer/oauthLogin/completedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "account/updated" + ], + "title": "Account/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/AccountUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Account/updatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "account/rateLimits/updated" + ], + "title": "Account/rateLimits/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/AccountRateLimitsUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Account/rateLimits/updatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "app/list/updated" + ], + "title": "App/list/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/AppListUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "App/list/updatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "item/reasoning/summaryTextDelta" + ], + "title": "Item/reasoning/summaryTextDeltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ReasoningSummaryTextDeltaNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/reasoning/summaryTextDeltaNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "item/reasoning/summaryPartAdded" + ], + "title": "Item/reasoning/summaryPartAddedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ReasoningSummaryPartAddedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/reasoning/summaryPartAddedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "item/reasoning/textDelta" + ], + "title": "Item/reasoning/textDeltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ReasoningTextDeltaNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/reasoning/textDeltaNotification", + "type": "object" + }, + { + "description": "Deprecated: Use `ContextCompaction` item type instead.", + "properties": { + "method": { + "enum": [ + "thread/compacted" + ], + "title": "Thread/compactedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ContextCompactedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/compactedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "model/rerouted" + ], + "title": "Model/reroutedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ModelReroutedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Model/reroutedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "deprecationNotice" + ], + "title": "DeprecationNoticeNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/DeprecationNoticeNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "DeprecationNoticeNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "configWarning" + ], + "title": "ConfigWarningNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ConfigWarningNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "ConfigWarningNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "fuzzyFileSearch/sessionUpdated" + ], + "title": "FuzzyFileSearch/sessionUpdatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FuzzyFileSearchSessionUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "FuzzyFileSearch/sessionUpdatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "fuzzyFileSearch/sessionCompleted" + ], + "title": "FuzzyFileSearch/sessionCompletedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FuzzyFileSearchSessionCompletedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "FuzzyFileSearch/sessionCompletedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/realtime/started" + ], + "title": "Thread/realtime/startedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadRealtimeStartedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/realtime/startedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/realtime/itemAdded" + ], + "title": "Thread/realtime/itemAddedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadRealtimeItemAddedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/realtime/itemAddedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/realtime/outputAudio/delta" + ], + "title": "Thread/realtime/outputAudio/deltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadRealtimeOutputAudioDeltaNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/realtime/outputAudio/deltaNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/realtime/error" + ], + "title": "Thread/realtime/errorNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadRealtimeErrorNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/realtime/errorNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/realtime/closed" + ], + "title": "Thread/realtime/closedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadRealtimeClosedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/realtime/closedNotification", + "type": "object" + }, + { + "description": "Notifies the user of world-writable directories on Windows, which cannot be protected by the sandbox.", + "properties": { + "method": { + "enum": [ + "windows/worldWritableWarning" + ], + "title": "Windows/worldWritableWarningNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/WindowsWorldWritableWarningNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Windows/worldWritableWarningNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "windowsSandbox/setupCompleted" + ], + "title": "WindowsSandbox/setupCompletedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/WindowsSandboxSetupCompletedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "WindowsSandbox/setupCompletedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "account/login/completed" + ], + "title": "Account/login/completedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/AccountLoginCompletedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Account/login/completedNotification", + "type": "object" + } + ], + "title": "ServerNotification" + }, + "ServerRequestResolvedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "requestId": { + "$ref": "#/definitions/RequestId" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "requestId", + "threadId" + ], + "title": "ServerRequestResolvedNotification", + "type": "object" + }, + "SessionNetworkProxyRuntime": { + "properties": { + "admin_addr": { + "type": "string" + }, + "http_addr": { + "type": "string" + }, + "socks_addr": { + "type": "string" + } + }, + "required": [ + "admin_addr", + "http_addr", + "socks_addr" + ], + "type": "object" + }, + "SessionSource": { + "oneOf": [ + { + "enum": [ + "cli", + "vscode", + "exec", + "appServer", + "unknown" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "subAgent": { + "$ref": "#/definitions/SubAgentSource" + } + }, + "required": [ + "subAgent" + ], + "title": "SubAgentSessionSource", + "type": "object" + } + ] + }, + "Settings": { + "description": "Settings for a collaboration mode.", + "properties": { + "developer_instructions": { + "type": [ + "string", + "null" + ] + }, + "model": { + "type": "string" + }, + "reasoning_effort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "model" + ], + "type": "object" + }, + "SkillDependencies": { + "properties": { + "tools": { + "items": { + "$ref": "#/definitions/SkillToolDependency" + }, + "type": "array" + } + }, + "required": [ + "tools" + ], + "type": "object" + }, + "SkillErrorInfo": { + "properties": { + "message": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "message", + "path" + ], + "type": "object" + }, + "SkillInterface": { + "properties": { + "brandColor": { + "type": [ + "string", + "null" + ] + }, + "defaultPrompt": { + "type": [ + "string", + "null" + ] + }, + "displayName": { + "type": [ + "string", + "null" + ] + }, + "iconLarge": { + "type": [ + "string", + "null" + ] + }, + "iconSmall": { + "type": [ + "string", + "null" + ] + }, + "shortDescription": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "SkillMetadata": { + "properties": { + "dependencies": { + "anyOf": [ + { + "$ref": "#/definitions/SkillDependencies" + }, + { + "type": "null" + } + ] + }, + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "interface": { + "anyOf": [ + { + "$ref": "#/definitions/SkillInterface" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "scope": { + "$ref": "#/definitions/SkillScope" + }, + "shortDescription": { + "description": "Legacy short_description from SKILL.md. Prefer SKILL.json interface.short_description.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "description", + "enabled", + "name", + "path", + "scope" + ], + "type": "object" + }, + "SkillScope": { + "enum": [ + "user", + "repo", + "system", + "admin" + ], + "type": "string" + }, + "SkillToolDependency": { + "properties": { + "command": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "transport": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string" + }, + "url": { + "type": [ + "string", + "null" + ] + }, + "value": { + "type": "string" + } + }, + "required": [ + "type", + "value" + ], + "type": "object" + }, + "SkillsConfigWriteParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "enabled": { + "type": "boolean" + }, + "path": { + "type": "string" + } + }, + "required": [ + "enabled", + "path" + ], + "title": "SkillsConfigWriteParams", + "type": "object" + }, + "SkillsConfigWriteResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "effectiveEnabled": { + "type": "boolean" + } + }, + "required": [ + "effectiveEnabled" + ], + "title": "SkillsConfigWriteResponse", + "type": "object" + }, + "SkillsListEntry": { + "properties": { + "cwd": { + "type": "string" + }, + "errors": { + "items": { + "$ref": "#/definitions/SkillErrorInfo" + }, + "type": "array" + }, + "skills": { + "items": { + "$ref": "#/definitions/SkillMetadata" + }, + "type": "array" + } + }, + "required": [ + "cwd", + "errors", + "skills" + ], + "type": "object" + }, + "SkillsListExtraRootsForCwd": { + "properties": { + "cwd": { + "type": "string" + }, + "extraUserRoots": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "cwd", + "extraUserRoots" + ], + "type": "object" + }, + "SkillsListParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cwds": { + "description": "When empty, defaults to the current session working directory.", + "items": { + "type": "string" + }, + "type": "array" + }, + "forceReload": { + "description": "When true, bypass the skills cache and re-scan skills from disk.", + "type": "boolean" + }, + "perCwdExtraUserRoots": { + "default": null, + "description": "Optional per-cwd extra roots to scan as user-scoped skills.", + "items": { + "$ref": "#/definitions/SkillsListExtraRootsForCwd" + }, + "type": [ + "array", + "null" + ] + } + }, + "title": "SkillsListParams", + "type": "object" + }, + "SkillsListResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/SkillsListEntry" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "title": "SkillsListResponse", + "type": "object" + }, + "SkillsRemoteReadParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "enabled": { + "default": false, + "type": "boolean" + }, + "hazelnutScope": { + "allOf": [ + { + "$ref": "#/definitions/HazelnutScope" + } + ], + "default": "example" + }, + "productSurface": { + "allOf": [ + { + "$ref": "#/definitions/ProductSurface" + } + ], + "default": "codex" + } + }, + "title": "SkillsRemoteReadParams", + "type": "object" + }, + "SkillsRemoteReadResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/RemoteSkillSummary" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "title": "SkillsRemoteReadResponse", + "type": "object" + }, + "SkillsRemoteWriteParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "hazelnutId": { + "type": "string" + } + }, + "required": [ + "hazelnutId" + ], + "title": "SkillsRemoteWriteParams", + "type": "object" + }, + "SkillsRemoteWriteResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "id": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "id", + "path" + ], + "title": "SkillsRemoteWriteResponse", + "type": "object" + }, + "StepStatus": { + "enum": [ + "pending", + "in_progress", + "completed" + ], + "type": "string" + }, + "SubAgentSource": { + "oneOf": [ + { + "enum": [ + "review", + "compact", + "memory_consolidation" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "thread_spawn": { + "properties": { + "agent_nickname": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "agent_role": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "depth": { + "format": "int32", + "type": "integer" + }, + "parent_thread_id": { + "$ref": "#/definitions/ThreadId" + } + }, + "required": [ + "depth", + "parent_thread_id" + ], + "type": "object" + } + }, + "required": [ + "thread_spawn" + ], + "title": "ThreadSpawnSubAgentSource", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "other": { + "type": "string" + } + }, + "required": [ + "other" + ], + "title": "OtherSubAgentSource", + "type": "object" + } + ] + }, + "TerminalInteractionNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "itemId": { + "type": "string" + }, + "processId": { + "type": "string" + }, + "stdin": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "itemId", + "processId", + "stdin", + "threadId", + "turnId" + ], + "title": "TerminalInteractionNotification", + "type": "object" + }, + "TextElement": { + "properties": { + "byteRange": { + "allOf": [ + { + "$ref": "#/definitions/ByteRange" + } + ], + "description": "Byte range in the parent `text` buffer that this element occupies." + }, + "placeholder": { + "description": "Optional human-readable placeholder for the element, displayed in the UI.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "byteRange" + ], + "type": "object" + }, + "TextPosition": { + "properties": { + "column": { + "description": "1-based column number (in Unicode scalar values).", + "format": "uint", + "minimum": 0.0, + "type": "integer" + }, + "line": { + "description": "1-based line number.", + "format": "uint", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "column", + "line" + ], + "type": "object" + }, + "TextRange": { + "properties": { + "end": { + "$ref": "#/definitions/TextPosition" + }, + "start": { + "$ref": "#/definitions/TextPosition" + } + }, + "required": [ + "end", + "start" + ], + "type": "object" + }, + "Thread": { + "properties": { + "agentNickname": { + "description": "Optional random unique nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "cliVersion": { + "description": "Version of the CLI that created the thread.", + "type": "string" + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the thread was created.", + "format": "int64", + "type": "integer" + }, + "cwd": { + "description": "Working directory captured for the thread.", + "type": "string" + }, + "ephemeral": { + "description": "Whether the thread is ephemeral and should not be materialized on disk.", + "type": "boolean" + }, + "gitInfo": { + "anyOf": [ + { + "$ref": "#/definitions/GitInfo" + }, + { + "type": "null" + } + ], + "description": "Optional Git metadata captured when the thread was created." + }, + "id": { + "type": "string" + }, + "modelProvider": { + "description": "Model provider used for this thread (for example, 'openai').", + "type": "string" + }, + "name": { + "description": "Optional user-facing thread title.", + "type": [ + "string", + "null" + ] + }, + "path": { + "description": "[UNSTABLE] Path to the thread on disk.", + "type": [ + "string", + "null" + ] + }, + "preview": { + "description": "Usually the first user message in the thread, if available.", + "type": "string" + }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/SessionSource" + } + ], + "description": "Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.)." + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/ThreadStatus" + } + ], + "description": "Current runtime status for the thread." + }, + "turns": { + "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", + "items": { + "$ref": "#/definitions/Turn" + }, + "type": "array" + }, + "updatedAt": { + "description": "Unix timestamp (in seconds) when the thread was last updated.", + "format": "int64", + "type": "integer" + } + }, + "required": [ + "cliVersion", + "createdAt", + "cwd", + "ephemeral", + "id", + "modelProvider", + "preview", + "source", + "status", + "turns", + "updatedAt" + ], + "type": "object" + }, + "ThreadActiveFlag": { + "enum": [ + "waitingOnApproval", + "waitingOnUserInput" + ], + "type": "string" + }, + "ThreadArchiveParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadArchiveParams", + "type": "object" + }, + "ThreadArchiveResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadArchiveResponse", + "type": "object" + }, + "ThreadArchivedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadArchivedNotification", + "type": "object" + }, + "ThreadClosedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadClosedNotification", + "type": "object" + }, + "ThreadCompactStartParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadCompactStartParams", + "type": "object" + }, + "ThreadCompactStartResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadCompactStartResponse", + "type": "object" + }, + "ThreadForkParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "There are two ways to fork a thread: 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. 2. By path: load the thread from disk by path and fork it into a new thread.\n\nIf using path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.", + "properties": { + "approvalPolicy": { + "anyOf": [ + { + "$ref": "#/definitions/AskForApproval" + }, + { + "type": "null" + } + ] + }, + "baseInstructions": { + "type": [ + "string", + "null" + ] + }, + "config": { + "additionalProperties": true, + "type": [ + "object", + "null" + ] + }, + "cwd": { + "type": [ + "string", + "null" + ] + }, + "developerInstructions": { + "type": [ + "string", + "null" + ] + }, + "model": { + "description": "Configuration overrides for the forked thread, if any.", + "type": [ + "string", + "null" + ] + }, + "modelProvider": { + "type": [ + "string", + "null" + ] + }, + "sandbox": { + "anyOf": [ + { + "$ref": "#/definitions/SandboxMode" + }, + { + "type": "null" + } + ] + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadForkParams", + "type": "object" + }, + "ThreadForkResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "approvalPolicy": { + "$ref": "#/definitions/AskForApproval" + }, + "cwd": { + "type": "string" + }, + "model": { + "type": "string" + }, + "modelProvider": { + "type": "string" + }, + "reasoningEffort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "sandbox": { + "$ref": "#/definitions/SandboxPolicy" + }, + "thread": { + "$ref": "#/definitions/Thread" + } + }, + "required": [ + "approvalPolicy", + "cwd", + "model", + "modelProvider", + "sandbox", + "thread" + ], + "title": "ThreadForkResponse", + "type": "object" + }, + "ThreadId": { + "type": "string" + }, + "ThreadItem": { + "oneOf": [ + { + "properties": { + "content": { + "items": { + "$ref": "#/definitions/UserInput" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "userMessage" + ], + "title": "UserMessageThreadItemType", + "type": "string" + } + }, + "required": [ + "content", + "id", + "type" + ], + "title": "UserMessageThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "phase": { + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase" + }, + { + "type": "null" + } + ], + "default": null + }, + "text": { + "type": "string" + }, + "type": { + "enum": [ + "agentMessage" + ], + "title": "AgentMessageThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "text", + "type" + ], + "title": "AgentMessageThreadItem", + "type": "object" + }, + { + "description": "EXPERIMENTAL - proposed plan item content. The completed plan item is authoritative and may not match the concatenation of `PlanDelta` text.", + "properties": { + "id": { + "type": "string" + }, + "text": { + "type": "string" + }, + "type": { + "enum": [ + "plan" + ], + "title": "PlanThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "text", + "type" + ], + "title": "PlanThreadItem", + "type": "object" + }, + { + "properties": { + "content": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "summary": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "enum": [ + "reasoning" + ], + "title": "ReasoningThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "type" + ], + "title": "ReasoningThreadItem", + "type": "object" + }, + { + "properties": { + "aggregatedOutput": { + "description": "The command's output, aggregated from stdout and stderr.", + "type": [ + "string", + "null" + ] + }, + "command": { + "description": "The command to be executed.", + "type": "string" + }, + "commandActions": { + "description": "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 be composed of many commands piped together.", + "items": { + "$ref": "#/definitions/CommandAction" + }, + "type": "array" + }, + "cwd": { + "description": "The command's working directory.", + "type": "string" + }, + "durationMs": { + "description": "The duration of the command execution in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "exitCode": { + "description": "The command's exit code.", + "format": "int32", + "type": [ + "integer", + "null" + ] + }, + "id": { + "type": "string" + }, + "processId": { + "description": "Identifier for the underlying PTY process (when available).", + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/CommandExecutionStatus" + }, + "type": { + "enum": [ + "commandExecution" + ], + "title": "CommandExecutionThreadItemType", + "type": "string" + } + }, + "required": [ + "command", + "commandActions", + "cwd", + "id", + "status", + "type" + ], + "title": "CommandExecutionThreadItem", + "type": "object" + }, + { + "properties": { + "changes": { + "items": { + "$ref": "#/definitions/FileUpdateChange" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/PatchApplyStatus" + }, + "type": { + "enum": [ + "fileChange" + ], + "title": "FileChangeThreadItemType", + "type": "string" + } + }, + "required": [ + "changes", + "id", + "status", + "type" + ], + "title": "FileChangeThreadItem", + "type": "object" + }, + { + "properties": { + "arguments": true, + "durationMs": { + "description": "The duration of the MCP tool call in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "error": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallError" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "result": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallResult" + }, + { + "type": "null" + } + ] + }, + "server": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/McpToolCallStatus" + }, + "tool": { + "type": "string" + }, + "type": { + "enum": [ + "mcpToolCall" + ], + "title": "McpToolCallThreadItemType", + "type": "string" + } + }, + "required": [ + "arguments", + "id", + "server", + "status", + "tool", + "type" + ], + "title": "McpToolCallThreadItem", + "type": "object" + }, + { + "properties": { + "arguments": true, + "contentItems": { + "items": { + "$ref": "#/definitions/DynamicToolCallOutputContentItem" + }, + "type": [ + "array", + "null" + ] + }, + "durationMs": { + "description": "The duration of the dynamic tool call in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "id": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/DynamicToolCallStatus" + }, + "success": { + "type": [ + "boolean", + "null" + ] + }, + "tool": { + "type": "string" + }, + "type": { + "enum": [ + "dynamicToolCall" + ], + "title": "DynamicToolCallThreadItemType", + "type": "string" + } + }, + "required": [ + "arguments", + "id", + "status", + "tool", + "type" + ], + "title": "DynamicToolCallThreadItem", + "type": "object" + }, + { + "properties": { + "agentsStates": { + "additionalProperties": { + "$ref": "#/definitions/CollabAgentState" + }, + "description": "Last known status of the target agents, when available.", + "type": "object" + }, + "id": { + "description": "Unique identifier for this collab tool call.", + "type": "string" + }, + "prompt": { + "description": "Prompt text sent as part of the collab tool call, when available.", + "type": [ + "string", + "null" + ] + }, + "receiverThreadIds": { + "description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.", + "items": { + "type": "string" + }, + "type": "array" + }, + "senderThreadId": { + "description": "Thread ID of the agent issuing the collab request.", + "type": "string" + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/CollabAgentToolCallStatus" + } + ], + "description": "Current status of the collab tool call." + }, + "tool": { + "allOf": [ + { + "$ref": "#/definitions/CollabAgentTool" + } + ], + "description": "Name of the collab tool that was invoked." + }, + "type": { + "enum": [ + "collabAgentToolCall" + ], + "title": "CollabAgentToolCallThreadItemType", + "type": "string" + } + }, + "required": [ + "agentsStates", + "id", + "receiverThreadIds", + "senderThreadId", + "status", + "tool", + "type" + ], + "title": "CollabAgentToolCallThreadItem", + "type": "object" + }, + { + "properties": { + "action": { + "anyOf": [ + { + "$ref": "#/definitions/WebSearchAction" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "query": { + "type": "string" + }, + "type": { + "enum": [ + "webSearch" + ], + "title": "WebSearchThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "query", + "type" + ], + "title": "WebSearchThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "enum": [ + "imageView" + ], + "title": "ImageViewThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "path", + "type" + ], + "title": "ImageViewThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "review": { + "type": "string" + }, + "type": { + "enum": [ + "enteredReviewMode" + ], + "title": "EnteredReviewModeThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "review", + "type" + ], + "title": "EnteredReviewModeThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "review": { + "type": "string" + }, + "type": { + "enum": [ + "exitedReviewMode" + ], + "title": "ExitedReviewModeThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "review", + "type" + ], + "title": "ExitedReviewModeThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "type": { + "enum": [ + "contextCompaction" + ], + "title": "ContextCompactionThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "type" + ], + "title": "ContextCompactionThreadItem", + "type": "object" + } + ] + }, + "ThreadListParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "archived": { + "description": "Optional archived filter; when set to true, only archived threads are returned. If false or null, only non-archived threads are returned.", + "type": [ + "boolean", + "null" + ] + }, + "cursor": { + "description": "Opaque pagination cursor returned by a previous call.", + "type": [ + "string", + "null" + ] + }, + "cwd": { + "description": "Optional cwd filter; when set, only threads whose session cwd exactly matches this path are returned.", + "type": [ + "string", + "null" + ] + }, + "limit": { + "description": "Optional page size; defaults to a reasonable server-side value.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "modelProviders": { + "description": "Optional provider filter; when set, only sessions recorded under these providers are returned. When present but empty, includes all providers.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "searchTerm": { + "description": "Optional substring filter for the extracted thread title.", + "type": [ + "string", + "null" + ] + }, + "sortKey": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSortKey" + }, + { + "type": "null" + } + ], + "description": "Optional sort key; defaults to created_at." + }, + "sourceKinds": { + "description": "Optional source filter; when set, only sessions from these source kinds are returned. When omitted or empty, defaults to interactive sources.", + "items": { + "$ref": "#/definitions/ThreadSourceKind" + }, + "type": [ + "array", + "null" + ] + } + }, + "title": "ThreadListParams", + "type": "object" + }, + "ThreadListResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/Thread" + }, + "type": "array" + }, + "nextCursor": { + "description": "Opaque cursor to pass to the next call to continue after the last item. if None, there are no more items to return.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "data" + ], + "title": "ThreadListResponse", + "type": "object" + }, + "ThreadLoadedListParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cursor": { + "description": "Opaque pagination cursor returned by a previous call.", + "type": [ + "string", + "null" + ] + }, + "limit": { + "description": "Optional page size; defaults to no limit.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "title": "ThreadLoadedListParams", + "type": "object" + }, + "ThreadLoadedListResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "data": { + "description": "Thread ids for sessions currently loaded in memory.", + "items": { + "type": "string" + }, + "type": "array" + }, + "nextCursor": { + "description": "Opaque cursor to pass to the next call to continue after the last item. if None, there are no more items to return.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "data" + ], + "title": "ThreadLoadedListResponse", + "type": "object" + }, + "ThreadNameUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + }, + "threadName": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "threadId" + ], + "title": "ThreadNameUpdatedNotification", + "type": "object" + }, + "ThreadReadParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "includeTurns": { + "default": false, + "description": "When true, include turns and their items from rollout history.", + "type": "boolean" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadReadParams", + "type": "object" + }, + "ThreadReadResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "thread": { + "$ref": "#/definitions/Thread" + } + }, + "required": [ + "thread" + ], + "title": "ThreadReadResponse", + "type": "object" + }, + "ThreadRealtimeAudioChunk": { + "description": "EXPERIMENTAL - thread realtime audio chunk.", + "properties": { + "data": { + "type": "string" + }, + "numChannels": { + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "sampleRate": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "samplesPerChannel": { + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "data", + "numChannels", + "sampleRate" + ], + "type": "object" + }, + "ThreadRealtimeClosedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - emitted when thread realtime transport closes.", + "properties": { + "reason": { + "type": [ + "string", + "null" + ] + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadRealtimeClosedNotification", + "type": "object" + }, + "ThreadRealtimeErrorNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - emitted when thread realtime encounters an error.", + "properties": { + "message": { + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "message", + "threadId" + ], + "title": "ThreadRealtimeErrorNotification", + "type": "object" + }, + "ThreadRealtimeItemAddedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - raw non-audio thread realtime item emitted by the backend.", + "properties": { + "item": true, + "threadId": { + "type": "string" + } + }, + "required": [ + "item", + "threadId" + ], + "title": "ThreadRealtimeItemAddedNotification", + "type": "object" + }, + "ThreadRealtimeOutputAudioDeltaNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - streamed output audio emitted by thread realtime.", + "properties": { + "audio": { + "$ref": "#/definitions/ThreadRealtimeAudioChunk" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "audio", + "threadId" + ], + "title": "ThreadRealtimeOutputAudioDeltaNotification", + "type": "object" + }, + "ThreadRealtimeStartedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - emitted when thread realtime startup is accepted.", + "properties": { + "sessionId": { + "type": [ + "string", + "null" + ] + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadRealtimeStartedNotification", + "type": "object" + }, + "ThreadResumeParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nThe precedence is: history > path > thread_id. If using history or path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.", + "properties": { + "approvalPolicy": { + "anyOf": [ + { + "$ref": "#/definitions/AskForApproval" + }, + { + "type": "null" + } + ] + }, + "baseInstructions": { + "type": [ + "string", + "null" + ] + }, + "config": { + "additionalProperties": true, + "type": [ + "object", + "null" + ] + }, + "cwd": { + "type": [ + "string", + "null" + ] + }, + "developerInstructions": { + "type": [ + "string", + "null" + ] + }, + "model": { + "description": "Configuration overrides for the resumed thread, if any.", + "type": [ + "string", + "null" + ] + }, + "modelProvider": { + "type": [ + "string", + "null" + ] + }, + "personality": { + "anyOf": [ + { + "$ref": "#/definitions/Personality" + }, + { + "type": "null" + } + ] + }, + "sandbox": { + "anyOf": [ + { + "$ref": "#/definitions/SandboxMode" + }, + { + "type": "null" + } + ] + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadResumeParams", + "type": "object" + }, + "ThreadResumeResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "approvalPolicy": { + "$ref": "#/definitions/AskForApproval" + }, + "cwd": { + "type": "string" + }, + "model": { + "type": "string" + }, + "modelProvider": { + "type": "string" + }, + "reasoningEffort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "sandbox": { + "$ref": "#/definitions/SandboxPolicy" + }, + "thread": { + "$ref": "#/definitions/Thread" + } + }, + "required": [ + "approvalPolicy", + "cwd", + "model", + "modelProvider", + "sandbox", + "thread" + ], + "title": "ThreadResumeResponse", + "type": "object" + }, + "ThreadRollbackParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "numTurns": { + "description": "The number of turns to drop from the end of the thread. Must be >= 1.\n\nThis only modifies the thread's history and does not revert local file changes that have been made by the agent. Clients are responsible for reverting these changes.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "numTurns", + "threadId" + ], + "title": "ThreadRollbackParams", + "type": "object" + }, + "ThreadRollbackResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "thread": { + "allOf": [ + { + "$ref": "#/definitions/Thread" + } + ], + "description": "The updated thread after applying the rollback, with `turns` populated.\n\nThe ThreadItems stored in each Turn are lossy since we explicitly do not persist all agent interactions, such as command executions. This is the same behavior as `thread/resume`." + } + }, + "required": [ + "thread" + ], + "title": "ThreadRollbackResponse", + "type": "object" + }, + "ThreadSetNameParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "name": { + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "name", + "threadId" + ], + "title": "ThreadSetNameParams", + "type": "object" + }, + "ThreadSetNameResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadSetNameResponse", + "type": "object" + }, + "ThreadSortKey": { + "enum": [ + "created_at", + "updated_at" + ], + "type": "string" + }, + "ThreadSourceKind": { + "enum": [ + "cli", + "vscode", + "exec", + "appServer", + "subAgent", + "subAgentReview", + "subAgentCompact", + "subAgentThreadSpawn", + "subAgentOther", + "unknown" + ], + "type": "string" + }, + "ThreadStartParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "approvalPolicy": { + "anyOf": [ + { + "$ref": "#/definitions/AskForApproval" + }, + { + "type": "null" + } + ] + }, + "baseInstructions": { + "type": [ + "string", + "null" + ] + }, + "config": { + "additionalProperties": true, + "type": [ + "object", + "null" + ] + }, + "cwd": { + "type": [ + "string", + "null" + ] + }, + "developerInstructions": { + "type": [ + "string", + "null" + ] + }, + "ephemeral": { + "type": [ + "boolean", + "null" + ] + }, + "model": { + "type": [ + "string", + "null" + ] + }, + "modelProvider": { + "type": [ + "string", + "null" + ] + }, + "personality": { + "anyOf": [ + { + "$ref": "#/definitions/Personality" + }, + { + "type": "null" + } + ] + }, + "sandbox": { + "anyOf": [ + { + "$ref": "#/definitions/SandboxMode" + }, + { + "type": "null" + } + ] + }, + "serviceName": { + "type": [ + "string", + "null" + ] + } + }, + "title": "ThreadStartParams", + "type": "object" + }, + "ThreadStartResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "approvalPolicy": { + "$ref": "#/definitions/AskForApproval" + }, + "cwd": { + "type": "string" + }, + "model": { + "type": "string" + }, + "modelProvider": { + "type": "string" + }, + "reasoningEffort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "sandbox": { + "$ref": "#/definitions/SandboxPolicy" + }, + "thread": { + "$ref": "#/definitions/Thread" + } + }, + "required": [ + "approvalPolicy", + "cwd", + "model", + "modelProvider", + "sandbox", + "thread" + ], + "title": "ThreadStartResponse", + "type": "object" + }, + "ThreadStartedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "thread": { + "$ref": "#/definitions/Thread" + } + }, + "required": [ + "thread" + ], + "title": "ThreadStartedNotification", + "type": "object" + }, + "ThreadStatus": { + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "notLoaded" + ], + "title": "NotLoadedThreadStatusType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "NotLoadedThreadStatus", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "idle" + ], + "title": "IdleThreadStatusType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "IdleThreadStatus", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "systemError" + ], + "title": "SystemErrorThreadStatusType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "SystemErrorThreadStatus", + "type": "object" + }, + { + "properties": { + "activeFlags": { + "items": { + "$ref": "#/definitions/ThreadActiveFlag" + }, + "type": "array" + }, + "type": { + "enum": [ + "active" + ], + "title": "ActiveThreadStatusType", + "type": "string" + } + }, + "required": [ + "activeFlags", + "type" + ], + "title": "ActiveThreadStatus", + "type": "object" + } + ] + }, + "ThreadStatusChangedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "status": { + "$ref": "#/definitions/ThreadStatus" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "status", + "threadId" + ], + "title": "ThreadStatusChangedNotification", + "type": "object" + }, + "ThreadTokenUsage": { + "properties": { + "last": { + "$ref": "#/definitions/TokenUsageBreakdown" + }, + "modelContextWindow": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "total": { + "$ref": "#/definitions/TokenUsageBreakdown" + } + }, + "required": [ + "last", + "total" + ], + "type": "object" + }, + "ThreadTokenUsageUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + }, + "tokenUsage": { + "$ref": "#/definitions/ThreadTokenUsage" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "threadId", + "tokenUsage", + "turnId" + ], + "title": "ThreadTokenUsageUpdatedNotification", + "type": "object" + }, + "ThreadUnarchiveParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadUnarchiveParams", + "type": "object" + }, + "ThreadUnarchiveResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "thread": { + "$ref": "#/definitions/Thread" + } + }, + "required": [ + "thread" + ], + "title": "ThreadUnarchiveResponse", + "type": "object" + }, + "ThreadUnarchivedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadUnarchivedNotification", + "type": "object" + }, + "ThreadUnsubscribeParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadUnsubscribeParams", + "type": "object" + }, + "ThreadUnsubscribeResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "status": { + "$ref": "#/definitions/ThreadUnsubscribeStatus" + } + }, + "required": [ + "status" + ], + "title": "ThreadUnsubscribeResponse", + "type": "object" + }, + "ThreadUnsubscribeStatus": { + "enum": [ + "notLoaded", + "notSubscribed", + "unsubscribed" + ], + "type": "string" + }, + "TokenUsage": { + "properties": { + "cached_input_tokens": { + "format": "int64", + "type": "integer" + }, + "input_tokens": { + "format": "int64", + "type": "integer" + }, + "output_tokens": { + "format": "int64", + "type": "integer" + }, + "reasoning_output_tokens": { + "format": "int64", + "type": "integer" + }, + "total_tokens": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "cached_input_tokens", + "input_tokens", + "output_tokens", + "reasoning_output_tokens", + "total_tokens" + ], + "type": "object" + }, + "TokenUsageBreakdown": { + "properties": { + "cachedInputTokens": { + "format": "int64", + "type": "integer" + }, + "inputTokens": { + "format": "int64", + "type": "integer" + }, + "outputTokens": { + "format": "int64", + "type": "integer" + }, + "reasoningOutputTokens": { + "format": "int64", + "type": "integer" + }, + "totalTokens": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "cachedInputTokens", + "inputTokens", + "outputTokens", + "reasoningOutputTokens", + "totalTokens" + ], + "type": "object" + }, + "TokenUsageInfo": { + "properties": { + "last_token_usage": { + "$ref": "#/definitions/TokenUsage" + }, + "model_context_window": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "total_token_usage": { + "$ref": "#/definitions/TokenUsage" + } + }, + "required": [ + "last_token_usage", + "total_token_usage" + ], + "type": "object" + }, + "Tool": { + "description": "Definition for a tool the client can call.", + "properties": { + "_meta": true, + "annotations": true, + "description": { + "type": [ + "string", + "null" + ] + }, + "icons": { + "items": true, + "type": [ + "array", + "null" + ] + }, + "inputSchema": true, + "name": { + "type": "string" + }, + "outputSchema": true, + "title": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "inputSchema", + "name" + ], + "type": "object" + }, + "ToolsV2": { + "properties": { + "view_image": { + "type": [ + "boolean", + "null" + ] + }, + "web_search": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, + "Turn": { + "properties": { + "error": { + "anyOf": [ + { + "$ref": "#/definitions/TurnError" + }, + { + "type": "null" + } + ], + "description": "Only populated when the Turn's status is failed." + }, + "id": { + "type": "string" + }, + "items": { + "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "items": { + "$ref": "#/definitions/ThreadItem" + }, + "type": "array" + }, + "status": { + "$ref": "#/definitions/TurnStatus" + } + }, + "required": [ + "id", + "items", + "status" + ], + "type": "object" + }, + "TurnAbortReason": { + "enum": [ + "interrupted", + "replaced", + "review_ended" + ], + "type": "string" + }, + "TurnCompletedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + }, + "turn": { + "$ref": "#/definitions/Turn" + } + }, + "required": [ + "threadId", + "turn" + ], + "title": "TurnCompletedNotification", + "type": "object" + }, + "TurnDiffUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Notification that the turn-level unified diff has changed. Contains the latest aggregated diff across all file changes in the turn.", + "properties": { + "diff": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "diff", + "threadId", + "turnId" + ], + "title": "TurnDiffUpdatedNotification", + "type": "object" + }, + "TurnError": { + "properties": { + "additionalDetails": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/definitions/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + }, + "TurnInterruptParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "threadId", + "turnId" + ], + "title": "TurnInterruptParams", + "type": "object" + }, + "TurnInterruptResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "TurnInterruptResponse", + "type": "object" + }, + "TurnItem": { + "oneOf": [ + { + "properties": { + "content": { + "items": { + "$ref": "#/definitions/UserInput" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "UserMessage" + ], + "title": "UserMessageTurnItemType", + "type": "string" + } + }, + "required": [ + "content", + "id", + "type" + ], + "title": "UserMessageTurnItem", + "type": "object" + }, + { + "description": "Assistant-authored message payload used in turn-item streams.\n\n`phase` is optional because not all providers/models emit it. Consumers should use it when present, but retain legacy completion semantics when it is `None`.", + "properties": { + "content": { + "items": { + "$ref": "#/definitions/AgentMessageContent" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "phase": { + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase" + }, + { + "type": "null" + } + ], + "description": "Optional phase metadata carried through from `ResponseItem::Message`.\n\nThis is currently used by TUI rendering to distinguish mid-turn commentary from a final answer and avoid status-indicator jitter." + }, + "type": { + "enum": [ + "AgentMessage" + ], + "title": "AgentMessageTurnItemType", + "type": "string" + } + }, + "required": [ + "content", + "id", + "type" + ], + "title": "AgentMessageTurnItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "text": { + "type": "string" + }, + "type": { + "enum": [ + "Plan" + ], + "title": "PlanTurnItemType", + "type": "string" + } + }, + "required": [ + "id", + "text", + "type" + ], + "title": "PlanTurnItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "raw_content": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "summary_text": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "enum": [ + "Reasoning" + ], + "title": "ReasoningTurnItemType", + "type": "string" + } + }, + "required": [ + "id", + "summary_text", + "type" + ], + "title": "ReasoningTurnItem", + "type": "object" + }, + { + "properties": { + "action": { + "$ref": "#/definitions/WebSearchAction" + }, + "id": { + "type": "string" + }, + "query": { + "type": "string" + }, + "type": { + "enum": [ + "WebSearch" + ], + "title": "WebSearchTurnItemType", + "type": "string" + } + }, + "required": [ + "action", + "id", + "query", + "type" + ], + "title": "WebSearchTurnItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "type": { + "enum": [ + "ContextCompaction" + ], + "title": "ContextCompactionTurnItemType", + "type": "string" + } + }, + "required": [ + "id", + "type" + ], + "title": "ContextCompactionTurnItem", + "type": "object" + } + ] + }, + "TurnPlanStep": { + "properties": { + "status": { + "$ref": "#/definitions/TurnPlanStepStatus" + }, + "step": { + "type": "string" + } + }, + "required": [ + "status", + "step" + ], + "type": "object" + }, + "TurnPlanStepStatus": { + "enum": [ + "pending", + "inProgress", + "completed" + ], + "type": "string" + }, + "TurnPlanUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "explanation": { + "type": [ + "string", + "null" + ] + }, + "plan": { + "items": { + "$ref": "#/definitions/TurnPlanStep" + }, + "type": "array" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "plan", + "threadId", + "turnId" + ], + "title": "TurnPlanUpdatedNotification", + "type": "object" + }, + "TurnStartParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "approvalPolicy": { + "anyOf": [ + { + "$ref": "#/definitions/AskForApproval" + }, + { + "type": "null" + } + ], + "description": "Override the approval policy for this turn and subsequent turns." + }, + "cwd": { + "description": "Override the working directory for this turn and subsequent turns.", + "type": [ + "string", + "null" + ] + }, + "effort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ], + "description": "Override the reasoning effort for this turn and subsequent turns." + }, + "input": { + "items": { + "$ref": "#/definitions/UserInput" + }, + "type": "array" + }, + "model": { + "description": "Override the model for this turn and subsequent turns.", + "type": [ + "string", + "null" + ] + }, + "outputSchema": { + "description": "Optional JSON Schema used to constrain the final assistant message for this turn." + }, + "personality": { + "anyOf": [ + { + "$ref": "#/definitions/Personality" + }, + { + "type": "null" + } + ], + "description": "Override the personality for this turn and subsequent turns." + }, + "sandboxPolicy": { + "anyOf": [ + { + "$ref": "#/definitions/SandboxPolicy" + }, + { + "type": "null" + } + ], + "description": "Override the sandbox policy for this turn and subsequent turns." + }, + "summary": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningSummary" + }, + { + "type": "null" + } + ], + "description": "Override the reasoning summary for this turn and subsequent turns." + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "input", + "threadId" + ], + "title": "TurnStartParams", + "type": "object" + }, + "TurnStartResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "turn": { + "$ref": "#/definitions/Turn" + } + }, + "required": [ + "turn" + ], + "title": "TurnStartResponse", + "type": "object" + }, + "TurnStartedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + }, + "turn": { + "$ref": "#/definitions/Turn" + } + }, + "required": [ + "threadId", + "turn" + ], + "title": "TurnStartedNotification", + "type": "object" + }, + "TurnStatus": { + "enum": [ + "completed", + "interrupted", + "failed", + "inProgress" + ], + "type": "string" + }, + "TurnSteerParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "expectedTurnId": { + "description": "Required active turn id precondition. The request fails when it does not match the currently active turn.", + "type": "string" + }, + "input": { + "items": { + "$ref": "#/definitions/UserInput" + }, + "type": "array" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "expectedTurnId", + "input", + "threadId" + ], + "title": "TurnSteerParams", + "type": "object" + }, + "TurnSteerResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "turnId": { + "type": "string" + } + }, + "required": [ + "turnId" + ], + "title": "TurnSteerResponse", + "type": "object" + }, + "UserInput": { + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "text_elements": { + "default": [], + "description": "UI-defined spans within `text` used to render or persist special elements.", + "items": { + "$ref": "#/definitions/TextElement" + }, + "type": "array" + }, + "type": { + "enum": [ + "text" + ], + "title": "TextUserInputType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "TextUserInput", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "image" + ], + "title": "ImageUserInputType", + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "type", + "url" + ], + "title": "ImageUserInput", + "type": "object" + }, + { + "properties": { + "path": { + "type": "string" + }, + "type": { + "enum": [ + "localImage" + ], + "title": "LocalImageUserInputType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "LocalImageUserInput", + "type": "object" + }, + { + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "enum": [ + "skill" + ], + "title": "SkillUserInputType", + "type": "string" + } + }, + "required": [ + "name", + "path", + "type" + ], + "title": "SkillUserInput", + "type": "object" + }, + { + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "enum": [ + "mention" + ], + "title": "MentionUserInputType", + "type": "string" + } + }, + "required": [ + "name", + "path", + "type" + ], + "title": "MentionUserInput", + "type": "object" + } + ] + }, + "Verbosity": { + "description": "Controls output length/detail on GPT-5 models via the Responses API. Serialized with lowercase values to match the OpenAI API.", + "enum": [ + "low", + "medium", + "high" + ], + "type": "string" + }, + "WebSearchAction": { + "oneOf": [ + { + "properties": { + "queries": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "search" + ], + "title": "SearchWebSearchActionType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "SearchWebSearchAction", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "open_page" + ], + "title": "OpenPageWebSearchActionType", + "type": "string" + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "type" + ], + "title": "OpenPageWebSearchAction", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "find_in_page" + ], + "title": "FindInPageWebSearchActionType", + "type": "string" + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "type" + ], + "title": "FindInPageWebSearchAction", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "other" + ], + "title": "OtherWebSearchActionType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "OtherWebSearchAction", + "type": "object" + } + ] + }, + "WebSearchMode": { + "enum": [ + "disabled", + "cached", + "live" + ], + "type": "string" + }, + "WindowsSandboxSetupCompletedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "error": { + "type": [ + "string", + "null" + ] + }, + "mode": { + "$ref": "#/definitions/WindowsSandboxSetupMode" + }, + "success": { + "type": "boolean" + } + }, + "required": [ + "mode", + "success" + ], + "title": "WindowsSandboxSetupCompletedNotification", + "type": "object" + }, + "WindowsSandboxSetupMode": { + "enum": [ + "elevated", + "unelevated" + ], + "type": "string" + }, + "WindowsSandboxSetupStartParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "mode": { + "$ref": "#/definitions/WindowsSandboxSetupMode" + } + }, + "required": [ + "mode" + ], + "title": "WindowsSandboxSetupStartParams", + "type": "object" + }, + "WindowsSandboxSetupStartResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "started": { + "type": "boolean" + } + }, + "required": [ + "started" + ], + "title": "WindowsSandboxSetupStartResponse", + "type": "object" + }, + "WindowsWorldWritableWarningNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "extraCount": { + "format": "uint", + "minimum": 0.0, + "type": "integer" + }, + "failedScan": { + "type": "boolean" + }, + "samplePaths": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "extraCount", + "failedScan", + "samplePaths" + ], + "title": "WindowsWorldWritableWarningNotification", + "type": "object" + }, + "WriteStatus": { + "enum": [ + "ok", + "okOverridden" + ], + "type": "string" + } + }, + "title": "CodexAppServerProtocolV2", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json index 4511515d17..626db8daeb 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -323,6 +323,16 @@ } ] }, + "service_tier": { + "anyOf": [ + { + "$ref": "#/definitions/ServiceTier" + }, + { + "type": "null" + } + ] + }, "tools": { "anyOf": [ { @@ -608,6 +618,16 @@ } ] }, + "service_tier": { + "anyOf": [ + { + "$ref": "#/definitions/ServiceTier" + }, + { + "type": "null" + } + ] + }, "web_search": { "anyOf": [ { @@ -685,6 +705,12 @@ }, "type": "object" }, + "ServiceTier": { + "enum": [ + "fast" + ], + "type": "string" + }, "ToolsV2": { "properties": { "view_image": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json index 2b832d5bf1..ff0bf3f044 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json @@ -50,6 +50,12 @@ "danger-full-access" ], "type": "string" + }, + "ServiceTier": { + "enum": [ + "fast" + ], + "type": "string" } }, "description": "There are two ways to fork a thread: 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. 2. By path: load the thread from disk by path and fork it into a new thread.\n\nIf using path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.", @@ -112,6 +118,23 @@ } ] }, + "serviceTier": { + "anyOf": [ + { + "anyOf": [ + { + "$ref": "#/definitions/ServiceTier" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, "threadId": { "type": "string" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index fd38876505..3e4dca5b1c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -738,6 +738,12 @@ } ] }, + "ServiceTier": { + "enum": [ + "fast" + ], + "type": "string" + }, "SessionSource": { "oneOf": [ { @@ -1906,6 +1912,16 @@ "sandbox": { "$ref": "#/definitions/SandboxPolicy" }, + "serviceTier": { + "anyOf": [ + { + "$ref": "#/definitions/ServiceTier" + }, + { + "type": "null" + } + ] + }, "thread": { "$ref": "#/definitions/Thread" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index ef7607d3ca..353bda693d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -738,6 +738,12 @@ ], "type": "string" }, + "ServiceTier": { + "enum": [ + "fast" + ], + "type": "string" + }, "WebSearchAction": { "oneOf": [ { @@ -910,6 +916,23 @@ } ] }, + "serviceTier": { + "anyOf": [ + { + "anyOf": [ + { + "$ref": "#/definitions/ServiceTier" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, "threadId": { "type": "string" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 56e268bb53..d9fbc0d02c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -738,6 +738,12 @@ } ] }, + "ServiceTier": { + "enum": [ + "fast" + ], + "type": "string" + }, "SessionSource": { "oneOf": [ { @@ -1906,6 +1912,16 @@ "sandbox": { "$ref": "#/definitions/SandboxPolicy" }, + "serviceTier": { + "anyOf": [ + { + "$ref": "#/definitions/ServiceTier" + }, + { + "type": "null" + } + ] + }, "thread": { "$ref": "#/definitions/Thread" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json index 5f2dda11a1..311b7b40cd 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json @@ -75,6 +75,12 @@ "danger-full-access" ], "type": "string" + }, + "ServiceTier": { + "enum": [ + "fast" + ], + "type": "string" } }, "properties": { @@ -156,6 +162,23 @@ "string", "null" ] + }, + "serviceTier": { + "anyOf": [ + { + "anyOf": [ + { + "$ref": "#/definitions/ServiceTier" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] } }, "title": "ThreadStartParams", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 043354b68a..ee8b384998 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -738,6 +738,12 @@ } ] }, + "ServiceTier": { + "enum": [ + "fast" + ], + "type": "string" + }, "SessionSource": { "oneOf": [ { @@ -1906,6 +1912,16 @@ "sandbox": { "$ref": "#/definitions/SandboxPolicy" }, + "serviceTier": { + "anyOf": [ + { + "$ref": "#/definitions/ServiceTier" + }, + { + "type": "null" + } + ] + }, "thread": { "$ref": "#/definitions/Thread" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index bc15473cc0..0a769c3525 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -299,6 +299,12 @@ } ] }, + "ServiceTier": { + "enum": [ + "fast" + ], + "type": "string" + }, "Settings": { "description": "Settings for a collaboration mode.", "properties": { @@ -539,6 +545,24 @@ ], "description": "Override the sandbox policy for this turn and subsequent turns." }, + "serviceTier": { + "anyOf": [ + { + "anyOf": [ + { + "$ref": "#/definitions/ServiceTier" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ], + "description": "Override the service tier for this turn and subsequent turns." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/RealtimeEvent.ts b/codex-rs/app-server-protocol/schema/typescript/RealtimeEvent.ts index 6297beebef..8286f34cad 100644 --- a/codex-rs/app-server-protocol/schema/typescript/RealtimeEvent.ts +++ b/codex-rs/app-server-protocol/schema/typescript/RealtimeEvent.ts @@ -2,6 +2,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { RealtimeAudioFrame } from "./RealtimeAudioFrame"; +import type { RealtimeHandoffRequested } from "./RealtimeHandoffRequested"; import type { JsonValue } from "./serde_json/JsonValue"; -export type RealtimeEvent = { "SessionCreated": { session_id: string, } } | { "SessionUpdated": { backend_prompt: string | null, } } | { "AudioOut": RealtimeAudioFrame } | { "ConversationItemAdded": JsonValue } | { "Error": string }; +export type RealtimeEvent = { "SessionUpdated": { session_id: string, instructions: string | null, } } | { "AudioOut": RealtimeAudioFrame } | { "ConversationItemAdded": JsonValue } | { "ConversationItemDone": { item_id: string, } } | { "HandoffRequested": RealtimeHandoffRequested } | { "Error": string }; diff --git a/codex-rs/app-server-protocol/schema/typescript/RealtimeHandoffMessage.ts b/codex-rs/app-server-protocol/schema/typescript/RealtimeHandoffMessage.ts new file mode 100644 index 0000000000..39b77cfdf1 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/RealtimeHandoffMessage.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type RealtimeHandoffMessage = { role: string, text: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/RealtimeHandoffRequested.ts b/codex-rs/app-server-protocol/schema/typescript/RealtimeHandoffRequested.ts new file mode 100644 index 0000000000..f3426706aa --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/RealtimeHandoffRequested.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { RealtimeHandoffMessage } from "./RealtimeHandoffMessage"; + +export type RealtimeHandoffRequested = { handoff_id: string, item_id: string, input_transcript: string, messages: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/SendUserTurnParams.ts b/codex-rs/app-server-protocol/schema/typescript/SendUserTurnParams.ts index dc4cfba8f5..063191649a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/SendUserTurnParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/SendUserTurnParams.ts @@ -6,10 +6,11 @@ import type { InputItem } from "./InputItem"; import type { ReasoningEffort } from "./ReasoningEffort"; import type { ReasoningSummary } from "./ReasoningSummary"; import type { SandboxPolicy } from "./SandboxPolicy"; +import type { ServiceTier } from "./ServiceTier"; import type { ThreadId } from "./ThreadId"; import type { JsonValue } from "./serde_json/JsonValue"; -export type SendUserTurnParams = { conversationId: ThreadId, items: Array, cwd: string, approvalPolicy: AskForApproval, sandboxPolicy: SandboxPolicy, model: string, effort: ReasoningEffort | null, summary: ReasoningSummary, +export type SendUserTurnParams = { conversationId: ThreadId, items: Array, cwd: string, approvalPolicy: AskForApproval, sandboxPolicy: SandboxPolicy, model: string, serviceTier?: ServiceTier | null | null, effort: ReasoningEffort | null, summary: ReasoningSummary, /** * Optional JSON Schema used to constrain the final assistant message for this turn. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/ServiceTier.ts b/codex-rs/app-server-protocol/schema/typescript/ServiceTier.ts new file mode 100644 index 0000000000..bb2b5103e6 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/ServiceTier.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ServiceTier = "fast"; diff --git a/codex-rs/app-server-protocol/schema/typescript/SessionConfiguredEvent.ts b/codex-rs/app-server-protocol/schema/typescript/SessionConfiguredEvent.ts index d964f44451..b4696a0e4c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/SessionConfiguredEvent.ts +++ b/codex-rs/app-server-protocol/schema/typescript/SessionConfiguredEvent.ts @@ -5,6 +5,7 @@ import type { AskForApproval } from "./AskForApproval"; import type { EventMsg } from "./EventMsg"; import type { ReasoningEffort } from "./ReasoningEffort"; import type { SandboxPolicy } from "./SandboxPolicy"; +import type { ServiceTier } from "./ServiceTier"; import type { SessionNetworkProxyRuntime } from "./SessionNetworkProxyRuntime"; import type { ThreadId } from "./ThreadId"; @@ -16,7 +17,7 @@ thread_name?: string, /** * Tell the client what model is being queried. */ -model: string, model_provider_id: string, +model: string, model_provider_id: string, service_tier: ServiceTier | null, /** * When to escalate for approval for execution */ diff --git a/codex-rs/app-server-protocol/schema/typescript/SessionConfiguredNotification.ts b/codex-rs/app-server-protocol/schema/typescript/SessionConfiguredNotification.ts index 3dee74aa3a..0fd5edcb3b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/SessionConfiguredNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/SessionConfiguredNotification.ts @@ -3,6 +3,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { EventMsg } from "./EventMsg"; import type { ReasoningEffort } from "./ReasoningEffort"; +import type { ServiceTier } from "./ServiceTier"; import type { ThreadId } from "./ThreadId"; -export type SessionConfiguredNotification = { sessionId: ThreadId, model: string, reasoningEffort: ReasoningEffort | null, historyLogId: bigint, historyEntryCount: number, initialMessages: Array | null, rolloutPath: string, }; +export type SessionConfiguredNotification = { sessionId: ThreadId, model: string, serviceTier: ServiceTier | null, reasoningEffort: ReasoningEffort | null, historyLogId: bigint, historyEntryCount: number, initialMessages: Array | null, rolloutPath: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index bc5b0297b8..81e08b1596 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -161,6 +161,8 @@ export type { RealtimeConversationClosedEvent } from "./RealtimeConversationClos export type { RealtimeConversationRealtimeEvent } from "./RealtimeConversationRealtimeEvent"; export type { RealtimeConversationStartedEvent } from "./RealtimeConversationStartedEvent"; export type { RealtimeEvent } from "./RealtimeEvent"; +export type { RealtimeHandoffMessage } from "./RealtimeHandoffMessage"; +export type { RealtimeHandoffRequested } from "./RealtimeHandoffRequested"; export type { ReasoningContentDeltaEvent } from "./ReasoningContentDeltaEvent"; export type { ReasoningEffort } from "./ReasoningEffort"; export type { ReasoningItem } from "./ReasoningItem"; @@ -198,6 +200,7 @@ export type { SendUserTurnParams } from "./SendUserTurnParams"; export type { SendUserTurnResponse } from "./SendUserTurnResponse"; export type { ServerNotification } from "./ServerNotification"; export type { ServerRequest } from "./ServerRequest"; +export type { ServiceTier } from "./ServiceTier"; export type { SessionConfiguredEvent } from "./SessionConfiguredEvent"; export type { SessionConfiguredNotification } from "./SessionConfiguredNotification"; export type { SessionNetworkProxyRuntime } from "./SessionNetworkProxyRuntime"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts index aee0ac3383..fb5d6ecbb9 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts @@ -4,6 +4,7 @@ import type { ForcedLoginMethod } from "../ForcedLoginMethod"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; +import type { ServiceTier } from "../ServiceTier"; import type { Verbosity } from "../Verbosity"; import type { WebSearchMode } from "../WebSearchMode"; import type { JsonValue } from "../serde_json/JsonValue"; @@ -14,4 +15,4 @@ import type { SandboxMode } from "./SandboxMode"; import type { SandboxWorkspaceWrite } from "./SandboxWorkspaceWrite"; import type { ToolsV2 } from "./ToolsV2"; -export type Config = {model: string | null, review_model: string | null, model_context_window: bigint | null, model_auto_compact_token_limit: bigint | null, model_provider: string | null, approval_policy: AskForApproval | null, sandbox_mode: SandboxMode | null, sandbox_workspace_write: SandboxWorkspaceWrite | null, forced_chatgpt_workspace_id: string | null, forced_login_method: ForcedLoginMethod | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, profile: string | null, profiles: { [key in string]?: ProfileV2 }, instructions: string | null, developer_instructions: string | null, compact_prompt: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, analytics: AnalyticsConfig | null} & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); +export type Config = {model: string | null, review_model: string | null, model_context_window: bigint | null, model_auto_compact_token_limit: bigint | null, model_provider: string | null, approval_policy: AskForApproval | null, sandbox_mode: SandboxMode | null, sandbox_workspace_write: SandboxWorkspaceWrite | null, forced_chatgpt_workspace_id: string | null, forced_login_method: ForcedLoginMethod | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, profile: string | null, profiles: { [key in string]?: ProfileV2 }, instructions: string | null, developer_instructions: string | null, compact_prompt: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, service_tier: ServiceTier | null, analytics: AnalyticsConfig | null} & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ProfileV2.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ProfileV2.ts index 56428ba7ab..81d20993cb 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ProfileV2.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ProfileV2.ts @@ -3,9 +3,10 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; +import type { ServiceTier } from "../ServiceTier"; import type { Verbosity } from "../Verbosity"; import type { WebSearchMode } from "../WebSearchMode"; import type { JsonValue } from "../serde_json/JsonValue"; import type { AskForApproval } from "./AskForApproval"; -export type ProfileV2 = { model: string | null, model_provider: string | null, approval_policy: AskForApproval | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, web_search: WebSearchMode | null, chatgpt_base_url: string | null, } & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); +export type ProfileV2 = { model: string | null, model_provider: string | null, approval_policy: AskForApproval | null, service_tier: ServiceTier | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, web_search: WebSearchMode | null, chatgpt_base_url: string | null, } & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts index 742e4d7032..b071bc8526 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts @@ -1,6 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ServiceTier } from "../ServiceTier"; import type { JsonValue } from "../serde_json/JsonValue"; import type { AskForApproval } from "./AskForApproval"; import type { SandboxMode } from "./SandboxMode"; @@ -21,7 +22,7 @@ export type ThreadForkParams = {threadId: string, /** path?: string | null, /** * Configuration overrides for the forked thread, if any. */ -model?: string | null, modelProvider?: string | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, /** +model?: string | null, modelProvider?: string | null, serviceTier?: ServiceTier | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, /** * If true, persist additional rollout EventMsg variants required to * reconstruct a richer thread history on subsequent resume/fork/read. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts index a46480cb7b..6125448c08 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts @@ -2,8 +2,9 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ReasoningEffort } from "../ReasoningEffort"; +import type { ServiceTier } from "../ServiceTier"; import type { AskForApproval } from "./AskForApproval"; import type { SandboxPolicy } from "./SandboxPolicy"; import type { Thread } from "./Thread"; -export type ThreadForkResponse = { thread: Thread, model: string, modelProvider: string, cwd: string, approvalPolicy: AskForApproval, sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | null, }; +export type ThreadForkResponse = { thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: string, approvalPolicy: AskForApproval, sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts index e79c748cc7..cc12020bd0 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts @@ -3,6 +3,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { Personality } from "../Personality"; import type { ResponseItem } from "../ResponseItem"; +import type { ServiceTier } from "../ServiceTier"; import type { JsonValue } from "../serde_json/JsonValue"; import type { AskForApproval } from "./AskForApproval"; import type { SandboxMode } from "./SandboxMode"; @@ -30,7 +31,7 @@ history?: Array | null, /** path?: string | null, /** * Configuration overrides for the resumed thread, if any. */ -model?: string | null, modelProvider?: string | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null, /** +model?: string | null, modelProvider?: string | null, serviceTier?: ServiceTier | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null, /** * If true, persist additional rollout EventMsg variants required to * reconstruct a richer thread history on subsequent resume/fork/read. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts index 6d7a70a6a9..91ca82419b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts @@ -2,8 +2,9 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ReasoningEffort } from "../ReasoningEffort"; +import type { ServiceTier } from "../ServiceTier"; import type { AskForApproval } from "./AskForApproval"; import type { SandboxPolicy } from "./SandboxPolicy"; import type { Thread } from "./Thread"; -export type ThreadResumeResponse = { thread: Thread, model: string, modelProvider: string, cwd: string, approvalPolicy: AskForApproval, sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | null, }; +export type ThreadResumeResponse = { thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: string, approvalPolicy: AskForApproval, sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts index 5f34f6198c..db73763e40 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts @@ -2,11 +2,12 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { Personality } from "../Personality"; +import type { ServiceTier } from "../ServiceTier"; import type { JsonValue } from "../serde_json/JsonValue"; import type { AskForApproval } from "./AskForApproval"; import type { SandboxMode } from "./SandboxMode"; -export type ThreadStartParams = {model?: string | null, modelProvider?: string | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, serviceName?: string | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null, ephemeral?: boolean | null, /** +export type ThreadStartParams = {model?: string | null, modelProvider?: string | null, serviceTier?: ServiceTier | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, serviceName?: string | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null, ephemeral?: boolean | null, /** * If true, opt into emitting raw Responses API items on the event stream. * This is for internal use only (e.g. Codex Cloud). */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts index 4a76f9af20..f4882ce6fb 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts @@ -2,8 +2,9 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ReasoningEffort } from "../ReasoningEffort"; +import type { ServiceTier } from "../ServiceTier"; import type { AskForApproval } from "./AskForApproval"; import type { SandboxPolicy } from "./SandboxPolicy"; import type { Thread } from "./Thread"; -export type ThreadStartResponse = { thread: Thread, model: string, modelProvider: string, cwd: string, approvalPolicy: AskForApproval, sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | null, }; +export type ThreadStartResponse = { thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: string, approvalPolicy: AskForApproval, sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index d595035dda..940aaa700d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -5,6 +5,7 @@ import type { CollaborationMode } from "../CollaborationMode"; import type { Personality } from "../Personality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; +import type { ServiceTier } from "../ServiceTier"; import type { JsonValue } from "../serde_json/JsonValue"; import type { AskForApproval } from "./AskForApproval"; import type { SandboxPolicy } from "./SandboxPolicy"; @@ -23,6 +24,9 @@ sandboxPolicy?: SandboxPolicy | null, /** * Override the model for this turn and subsequent turns. */ model?: string | null, /** + * Override the service tier for this turn and subsequent turns. + */ +serviceTier?: ServiceTier | null | null, /** * Override the reasoning effort for this turn and subsequent turns. */ effort?: ReasoningEffort | null, /** diff --git a/codex-rs/app-server-protocol/src/bin/export.rs b/codex-rs/app-server-protocol/src/bin/export.rs index d029ecbffa..10541d1fcb 100644 --- a/codex-rs/app-server-protocol/src/bin/export.rs +++ b/codex-rs/app-server-protocol/src/bin/export.rs @@ -14,9 +14,21 @@ struct Args { /// Optional Prettier executable path to format generated TypeScript files #[arg(short = 'p', long = "prettier", value_name = "PRETTIER_BIN")] prettier: Option, + + /// Include experimental API methods and fields in generated output. + #[arg(long = "experimental")] + experimental: bool, } fn main() -> Result<()> { let args = Args::parse(); - codex_app_server_protocol::generate_types(&args.out_dir, args.prettier.as_deref()) + codex_app_server_protocol::generate_ts_with_options( + &args.out_dir, + args.prettier.as_deref(), + codex_app_server_protocol::GenerateTsOptions { + experimental_api: args.experimental, + ..codex_app_server_protocol::GenerateTsOptions::default() + }, + )?; + codex_app_server_protocol::generate_json_with_experimental(&args.out_dir, args.experimental) } diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs index b48651c03c..78d5507277 100644 --- a/codex-rs/app-server-protocol/src/export.rs +++ b/codex-rs/app-server-protocol/src/export.rs @@ -37,6 +37,14 @@ use ts_rs::TS; const HEADER: &str = "// GENERATED CODE! DO NOT MODIFY BY HAND!\n\n"; const IGNORED_DEFINITIONS: &[&str] = &["Option<()>"]; const JSON_V1_ALLOWLIST: &[&str] = &["InitializeParams", "InitializeResponse"]; +const SPECIAL_DEFINITIONS: &[&str] = &[ + "ClientNotification", + "ClientRequest", + "EventMsg", + "ServerNotification", + "ServerRequest", +]; +const FLAT_V2_SHARED_DEFINITIONS: &[&str] = &["ClientRequest", "EventMsg", "ServerNotification"]; const V1_CLIENT_REQUEST_METHODS: &[&str] = &[ "newConversation", "getConversationSummary", @@ -223,6 +231,11 @@ pub fn generate_json_with_experimental(out_dir: &Path, experimental_api: bool) - out_dir.join("codex_app_server_protocol.schemas.json"), &bundle, )?; + let flat_v2_bundle = build_flat_v2_schema(&bundle)?; + write_pretty_json( + out_dir.join("codex_app_server_protocol.v2.schemas.json"), + &flat_v2_bundle, + )?; if !experimental_api { filter_experimental_json_files(out_dir)?; @@ -870,14 +883,6 @@ impl Depth { } fn build_schema_bundle(schemas: Vec) -> Result { - const SPECIAL_DEFINITIONS: &[&str] = &[ - "ClientNotification", - "ClientRequest", - "EventMsg", - "ServerNotification", - "ServerRequest", - ]; - let namespaced_types = collect_namespaced_types(&schemas); let mut definitions = Map::new(); @@ -895,6 +900,8 @@ fn build_schema_bundle(schemas: Vec) -> Result { if let Some(ref ns) = namespace { rewrite_refs_to_namespace(&mut value, ns); + } else { + rewrite_refs_to_known_namespaces(&mut value, &namespaced_types); } let mut forced_namespace_refs: Vec<(String, String)> = Vec::new(); @@ -958,6 +965,210 @@ fn build_schema_bundle(schemas: Vec) -> Result { Ok(Value::Object(root)) } +/// Build a datamodel-code-generator-friendly v2 bundle from the mixed export. +/// +/// The full bundle keeps v2 schemas nested under `definitions.v2`, plus a few +/// shared root definitions like `ClientRequest`, `EventMsg`, and +/// `ServerNotification`. Python codegen only walks one definitions map level, so +/// a direct feed would treat `v2` itself as a schema and miss unreferenced v2 +/// leaves. This helper flattens all v2 definitions to the root definitions map, +/// then pulls in the shared root schemas and any non-v2 transitive deps they +/// still reference. Keep the shared root unions intact here: some valid +/// request/notification/event variants are inline or only reference shared root +/// helpers, so filtering them by the presence of a `#/definitions/v2/` ref +/// would silently drop real API surface from the flat bundle. +fn build_flat_v2_schema(bundle: &Value) -> Result { + let Value::Object(root) = bundle else { + return Err(anyhow!("expected bundle root to be an object")); + }; + let definitions = root + .get("definitions") + .and_then(Value::as_object) + .ok_or_else(|| anyhow!("expected bundle definitions map"))?; + let v2_definitions = definitions + .get("v2") + .and_then(Value::as_object) + .ok_or_else(|| anyhow!("expected v2 namespace in bundle definitions"))?; + + let mut flat_root = root.clone(); + let title = root + .get("title") + .and_then(Value::as_str) + .unwrap_or("CodexAppServerProtocol"); + let mut flat_definitions = v2_definitions.clone(); + let mut shared_definitions = Map::new(); + let mut non_v2_refs = HashSet::new(); + + for shared in FLAT_V2_SHARED_DEFINITIONS { + let Some(shared_schema) = definitions.get(*shared) else { + continue; + }; + let shared_schema = shared_schema.clone(); + non_v2_refs.extend(collect_non_v2_refs(&shared_schema)); + shared_definitions.insert((*shared).to_string(), shared_schema); + } + + for name in collect_definition_dependencies(definitions, non_v2_refs) { + if name == "v2" || flat_definitions.contains_key(&name) { + continue; + } + if let Some(schema) = definitions.get(&name) { + flat_definitions.insert(name, schema.clone()); + } + } + + flat_definitions.extend(shared_definitions); + flat_root.insert("title".to_string(), Value::String(format!("{title}V2"))); + flat_root.insert("definitions".to_string(), Value::Object(flat_definitions)); + let mut flat_bundle = Value::Object(flat_root); + rewrite_ref_prefix(&mut flat_bundle, "#/definitions/v2/", "#/definitions/"); + ensure_no_ref_prefix(&flat_bundle, "#/definitions/v2/", "flat v2")?; + ensure_referenced_definitions_present(&flat_bundle, "flat v2")?; + Ok(flat_bundle) +} + +fn collect_non_v2_refs(value: &Value) -> HashSet { + let mut refs = HashSet::new(); + collect_non_v2_refs_inner(value, &mut refs); + refs +} + +fn collect_non_v2_refs_inner(value: &Value, refs: &mut HashSet) { + match value { + Value::Object(obj) => { + if let Some(Value::String(reference)) = obj.get("$ref") + && let Some(name) = reference.strip_prefix("#/definitions/") + && !reference.starts_with("#/definitions/v2/") + { + refs.insert(name.to_string()); + } + for child in obj.values() { + collect_non_v2_refs_inner(child, refs); + } + } + Value::Array(items) => { + for child in items { + collect_non_v2_refs_inner(child, refs); + } + } + _ => {} + } +} + +fn collect_definition_dependencies( + definitions: &Map, + names: HashSet, +) -> HashSet { + let mut seen = HashSet::new(); + let mut to_process: Vec = names.into_iter().collect(); + while let Some(name) = to_process.pop() { + if !seen.insert(name.clone()) { + continue; + } + let Some(schema) = definitions.get(&name) else { + continue; + }; + for dep in collect_non_v2_refs(schema) { + if !seen.contains(&dep) { + to_process.push(dep); + } + } + } + seen +} + +fn rewrite_ref_prefix(value: &mut Value, prefix: &str, replacement: &str) { + match value { + Value::Object(obj) => { + if let Some(Value::String(reference)) = obj.get_mut("$ref") { + *reference = reference.replace(prefix, replacement); + } + for child in obj.values_mut() { + rewrite_ref_prefix(child, prefix, replacement); + } + } + Value::Array(items) => { + for child in items { + rewrite_ref_prefix(child, prefix, replacement); + } + } + _ => {} + } +} + +fn ensure_no_ref_prefix(value: &Value, prefix: &str, label: &str) -> Result<()> { + if let Some(reference) = first_ref_with_prefix(value, prefix) { + return Err(anyhow!( + "{label} schema still references namespaced definitions; found {reference}" + )); + } + Ok(()) +} + +fn first_ref_with_prefix(value: &Value, prefix: &str) -> Option { + match value { + Value::Object(obj) => { + if let Some(Value::String(reference)) = obj.get("$ref") + && reference.starts_with(prefix) + { + return Some(reference.clone()); + } + obj.values() + .find_map(|child| first_ref_with_prefix(child, prefix)) + } + Value::Array(items) => items + .iter() + .find_map(|child| first_ref_with_prefix(child, prefix)), + _ => None, + } +} + +fn ensure_referenced_definitions_present(schema: &Value, label: &str) -> Result<()> { + let definitions = schema + .get("definitions") + .and_then(Value::as_object) + .ok_or_else(|| anyhow!("expected definitions map in {label} schema"))?; + let mut missing = HashSet::new(); + collect_missing_definitions(schema, definitions, &mut missing); + if missing.is_empty() { + return Ok(()); + } + let mut missing_names: Vec = missing.into_iter().collect(); + missing_names.sort(); + Err(anyhow!( + "{label} schema missing definitions: {}", + missing_names.join(", ") + )) +} + +fn collect_missing_definitions( + value: &Value, + definitions: &Map, + missing: &mut HashSet, +) { + match value { + Value::Object(obj) => { + if let Some(Value::String(reference)) = obj.get("$ref") + && let Some(name) = reference.strip_prefix("#/definitions/") + { + let name = name.split('/').next().unwrap_or(name); + if !definitions.contains_key(name) { + missing.insert(name.to_string()); + } + } + for child in obj.values() { + collect_missing_definitions(child, definitions, missing); + } + } + Value::Array(items) => { + for child in items { + collect_missing_definitions(child, definitions, missing); + } + } + _ => {} + } +} + fn insert_into_namespace( definitions: &mut Map, namespace: &str, @@ -1230,6 +1441,43 @@ fn rewrite_refs_to_namespace(value: &mut Value, ns: &str) { } } +/// Recursively rewrite bare root definition refs to the namespace that owns the +/// referenced type in the bundle. +/// +/// The mixed export contains shared root helper schemas that are intentionally +/// left outside the `v2` namespace, but some of their extracted child +/// definitions still contain refs like `#/definitions/ThreadId`. When the real +/// schema only exists under `#/definitions/v2/ThreadId`, those refs become +/// dangling and downstream codegen falls back to placeholder `Any` models. This +/// rewrite keeps the shared helpers at the root while retargeting their refs to +/// the namespaced definitions that actually exist. +fn rewrite_refs_to_known_namespaces(value: &mut Value, types: &HashMap) { + match value { + Value::Object(obj) => { + if let Some(Value::String(reference)) = obj.get_mut("$ref") + && let Some(suffix) = reference.strip_prefix("#/definitions/") + { + let (name, tail) = suffix + .split_once('/') + .map_or((suffix, None), |(name, tail)| (name, Some(tail))); + if let Some(ns) = namespace_for_definition(name, types) { + let tail = tail.map_or(String::new(), |rest| format!("/{rest}")); + *reference = format!("#/definitions/{ns}/{name}{tail}"); + } + } + for v in obj.values_mut() { + rewrite_refs_to_known_namespaces(v, types); + } + } + Value::Array(items) => { + for v in items.iter_mut() { + rewrite_refs_to_known_namespaces(v, types); + } + } + _ => {} + } +} + fn collect_namespaced_types(schemas: &[GeneratedSchema]) -> HashMap { let mut types = HashMap::new(); for schema in schemas { @@ -1986,6 +2234,284 @@ mod tests { Ok(()) } + #[test] + fn build_schema_bundle_rewrites_root_helper_refs_to_namespaced_defs() -> Result<()> { + let bundle = build_schema_bundle(vec![ + GeneratedSchema { + namespace: None, + logical_name: "LegacyEnvelope".to_string(), + in_v1_dir: false, + value: serde_json::json!({ + "title": "LegacyEnvelope", + "type": "object", + "properties": { + "current_thread": { "$ref": "#/definitions/ThreadId" }, + "turn_item": { "$ref": "#/definitions/TurnItem" } + }, + "definitions": { + "TurnItem": { + "type": "object", + "properties": { + "thread_id": { "$ref": "#/definitions/ThreadId" }, + "phase": { "$ref": "#/definitions/MessagePhase" }, + "content": { + "type": "array", + "items": { "$ref": "#/definitions/UserInput" } + } + } + } + } + }), + }, + GeneratedSchema { + namespace: Some("v2".to_string()), + logical_name: "ThreadId".to_string(), + in_v1_dir: false, + value: serde_json::json!({ + "title": "ThreadId", + "type": "string" + }), + }, + GeneratedSchema { + namespace: Some("v2".to_string()), + logical_name: "MessagePhase".to_string(), + in_v1_dir: false, + value: serde_json::json!({ + "title": "MessagePhase", + "type": "string" + }), + }, + GeneratedSchema { + namespace: Some("v2".to_string()), + logical_name: "UserInput".to_string(), + in_v1_dir: false, + value: serde_json::json!({ + "title": "UserInput", + "type": "string" + }), + }, + ])?; + + assert_eq!( + bundle["definitions"]["LegacyEnvelope"]["properties"]["current_thread"]["$ref"], + serde_json::json!("#/definitions/v2/ThreadId") + ); + assert_eq!( + bundle["definitions"]["LegacyEnvelope"]["properties"]["turn_item"]["$ref"], + serde_json::json!("#/definitions/TurnItem") + ); + assert_eq!( + bundle["definitions"]["TurnItem"]["properties"]["thread_id"]["$ref"], + serde_json::json!("#/definitions/v2/ThreadId") + ); + assert_eq!( + bundle["definitions"]["TurnItem"]["properties"]["phase"]["$ref"], + serde_json::json!("#/definitions/v2/MessagePhase") + ); + assert_eq!( + bundle["definitions"]["TurnItem"]["properties"]["content"]["items"]["$ref"], + serde_json::json!("#/definitions/v2/UserInput") + ); + + Ok(()) + } + + #[test] + fn build_flat_v2_schema_keeps_shared_root_schemas_and_dependencies() -> Result<()> { + let bundle = serde_json::json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CodexAppServerProtocol", + "type": "object", + "definitions": { + "ClientRequest": { + "oneOf": [ + { + "title": "StartRequest", + "type": "object", + "properties": { + "params": { "$ref": "#/definitions/v2/ThreadStartParams" }, + "shared": { "$ref": "#/definitions/SharedHelper" } + } + }, + { + "title": "InitializeRequest", + "type": "object", + "properties": { + "params": { "$ref": "#/definitions/InitializeParams" } + } + }, + { + "title": "LogoutRequest", + "type": "object", + "properties": { + "params": { "type": "null" } + } + } + ] + }, + "EventMsg": { + "oneOf": [ + { "$ref": "#/definitions/v2/ThreadStartedEventMsg" }, + { + "title": "WarningEventMsg", + "type": "object", + "properties": { + "message": { "type": "string" }, + "type": { + "enum": ["warning"], + "type": "string" + } + }, + "required": ["message", "type"] + } + ] + }, + "ServerNotification": { + "oneOf": [ + { "$ref": "#/definitions/v2/ThreadStartedNotification" }, + { + "title": "ServerRequestResolvedNotification", + "type": "object", + "properties": { + "params": { "$ref": "#/definitions/ServerRequestResolvedNotificationPayload" } + } + } + ] + }, + "SharedHelper": { + "type": "object", + "properties": { + "leaf": { "$ref": "#/definitions/SharedLeaf" } + } + }, + "SharedLeaf": { + "title": "SharedLeaf", + "type": "string" + }, + "InitializeParams": { + "title": "InitializeParams", + "type": "string" + }, + "ServerRequestResolvedNotificationPayload": { + "title": "ServerRequestResolvedNotificationPayload", + "type": "string" + }, + "v2": { + "ThreadStartParams": { + "title": "ThreadStartParams", + "type": "object", + "properties": { + "cwd": { "type": "string" } + } + }, + "ThreadStartResponse": { + "title": "ThreadStartResponse", + "type": "object", + "properties": { + "ok": { "type": "boolean" } + } + }, + "ThreadStartedEventMsg": { + "title": "ThreadStartedEventMsg", + "type": "object", + "properties": { + "thread_id": { "type": "string" } + } + }, + "ThreadStartedNotification": { + "title": "ThreadStartedNotification", + "type": "object", + "properties": { + "thread_id": { "type": "string" } + } + } + } + } + }); + + let flat_bundle = build_flat_v2_schema(&bundle)?; + let definitions = flat_bundle["definitions"] + .as_object() + .expect("flat v2 schema should include definitions"); + + assert_eq!( + flat_bundle["title"], + serde_json::json!("CodexAppServerProtocolV2") + ); + assert_eq!(definitions.contains_key("v2"), false); + assert_eq!(definitions.contains_key("ThreadStartParams"), true); + assert_eq!(definitions.contains_key("ThreadStartResponse"), true); + assert_eq!(definitions.contains_key("ThreadStartedEventMsg"), true); + assert_eq!(definitions.contains_key("ThreadStartedNotification"), true); + assert_eq!(definitions.contains_key("SharedHelper"), true); + assert_eq!(definitions.contains_key("SharedLeaf"), true); + assert_eq!(definitions.contains_key("InitializeParams"), true); + assert_eq!( + definitions.contains_key("ServerRequestResolvedNotificationPayload"), + true + ); + let client_request_titles: BTreeSet = definitions["ClientRequest"]["oneOf"] + .as_array() + .expect("ClientRequest should remain a oneOf") + .iter() + .map(|variant| { + variant["title"] + .as_str() + .expect("ClientRequest variant should have a title") + .to_string() + }) + .collect(); + assert_eq!( + client_request_titles, + BTreeSet::from([ + "InitializeRequest".to_string(), + "LogoutRequest".to_string(), + "StartRequest".to_string(), + ]) + ); + let event_titles: BTreeSet = definitions["EventMsg"]["oneOf"] + .as_array() + .expect("EventMsg should remain a oneOf") + .iter() + .map(|variant| { + variant + .get("title") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string() + }) + .collect(); + assert_eq!( + event_titles, + BTreeSet::from(["".to_string(), "WarningEventMsg".to_string(),]) + ); + let notification_titles: BTreeSet = definitions["ServerNotification"]["oneOf"] + .as_array() + .expect("ServerNotification should remain a oneOf") + .iter() + .map(|variant| { + variant + .get("title") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string() + }) + .collect(); + assert_eq!( + notification_titles, + BTreeSet::from([ + "".to_string(), + "ServerRequestResolvedNotification".to_string(), + ]) + ); + assert_eq!( + first_ref_with_prefix(&flat_bundle, "#/definitions/v2/").is_none(), + true + ); + + Ok(()) + } + #[test] fn experimental_type_fields_ts_filter_handles_interface_shape() -> Result<()> { let output_dir = std::env::temp_dir().join(format!("codex_ts_filter_{}", Uuid::now_v7())); @@ -2114,6 +2640,99 @@ export type Config = { stableField: Keep, unstableField: string | null } & ({ [k bundle_json.contains("MockExperimentalMethodResponse"), false ); + let flat_v2_bundle_json = + fs::read_to_string(output_dir.join("codex_app_server_protocol.v2.schemas.json"))?; + assert_eq!(flat_v2_bundle_json.contains("mockExperimentalField"), false); + assert_eq!(flat_v2_bundle_json.contains("additionalPermissions"), false); + assert_eq!( + flat_v2_bundle_json.contains("MockExperimentalMethodParams"), + false + ); + assert_eq!( + flat_v2_bundle_json.contains("MockExperimentalMethodResponse"), + false + ); + assert_eq!(flat_v2_bundle_json.contains("#/definitions/v2/"), false); + assert_eq!( + flat_v2_bundle_json.contains("\"title\": \"CodexAppServerProtocolV2\""), + true + ); + let flat_v2_bundle = + read_json_value(&output_dir.join("codex_app_server_protocol.v2.schemas.json"))?; + let definitions = flat_v2_bundle["definitions"] + .as_object() + .expect("flat v2 bundle should include definitions"); + let client_request_methods: BTreeSet = definitions["ClientRequest"]["oneOf"] + .as_array() + .expect("flat v2 ClientRequest should remain a oneOf") + .iter() + .filter_map(|variant| { + variant["properties"]["method"]["enum"] + .as_array() + .and_then(|values| values.first()) + .and_then(Value::as_str) + .map(str::to_string) + }) + .collect(); + let missing_client_request_methods: Vec = [ + "account/logout", + "account/rateLimits/read", + "config/mcpServer/reload", + "configRequirements/read", + "fuzzyFileSearch", + "initialize", + ] + .into_iter() + .filter(|method| !client_request_methods.contains(*method)) + .map(str::to_string) + .collect(); + assert_eq!(missing_client_request_methods, Vec::::new()); + let server_notification_methods: BTreeSet = + definitions["ServerNotification"]["oneOf"] + .as_array() + .expect("flat v2 ServerNotification should remain a oneOf") + .iter() + .filter_map(|variant| { + variant["properties"]["method"]["enum"] + .as_array() + .and_then(|values| values.first()) + .and_then(Value::as_str) + .map(str::to_string) + }) + .collect(); + let missing_server_notification_methods: Vec = [ + "fuzzyFileSearch/sessionCompleted", + "fuzzyFileSearch/sessionUpdated", + "serverRequest/resolved", + ] + .into_iter() + .filter(|method| !server_notification_methods.contains(*method)) + .map(str::to_string) + .collect(); + assert_eq!(missing_server_notification_methods, Vec::::new()); + let event_types: BTreeSet = definitions["EventMsg"]["oneOf"] + .as_array() + .expect("flat v2 EventMsg should remain a oneOf") + .iter() + .filter_map(|variant| { + variant["properties"]["type"]["enum"] + .as_array() + .and_then(|values| values.first()) + .and_then(Value::as_str) + .map(str::to_string) + }) + .collect(); + let missing_event_types: Vec = [ + "agent_message_delta", + "task_complete", + "warning", + "web_search_begin", + ] + .into_iter() + .filter(|event_type| !event_types.contains(*event_type)) + .map(str::to_string) + .collect(); + assert_eq!(missing_event_types, Vec::::new()); assert_eq!( output_dir .join("v2") diff --git a/codex-rs/app-server-protocol/src/jsonrpc_lite.rs b/codex-rs/app-server-protocol/src/jsonrpc_lite.rs index 372bdbe404..13d3e0cc9a 100644 --- a/codex-rs/app-server-protocol/src/jsonrpc_lite.rs +++ b/codex-rs/app-server-protocol/src/jsonrpc_lite.rs @@ -1,6 +1,7 @@ //! We do not do true JSON-RPC 2.0, as we neither send nor expect the //! "jsonrpc": "2.0" field. +use codex_protocol::protocol::W3cTraceContext; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; @@ -38,6 +39,10 @@ pub struct JSONRPCRequest { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub params: Option, + /// Optional W3C Trace Context for distributed tracing. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub trace: Option, } /// A notification which does not expect a response. diff --git a/codex-rs/app-server-protocol/src/protocol/mod.rs b/codex-rs/app-server-protocol/src/protocol/mod.rs index e269332438..1e0410f4b9 100644 --- a/codex-rs/app-server-protocol/src/protocol/mod.rs +++ b/codex-rs/app-server-protocol/src/protocol/mod.rs @@ -3,6 +3,7 @@ pub mod common; mod mappers; +mod serde_helpers; pub mod thread_history; pub mod v1; pub mod v2; diff --git a/codex-rs/app-server-protocol/src/protocol/serde_helpers.rs b/codex-rs/app-server-protocol/src/protocol/serde_helpers.rs new file mode 100644 index 0000000000..0e35ebdba7 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/serde_helpers.rs @@ -0,0 +1,23 @@ +use serde::Deserialize; +use serde::Deserializer; +use serde::Serialize; +use serde::Serializer; + +pub fn deserialize_double_option<'de, T, D>(deserializer: D) -> Result>, D::Error> +where + T: Deserialize<'de>, + D: Deserializer<'de>, +{ + serde_with::rust::double_option::deserialize(deserializer) +} + +pub fn serialize_double_option( + value: &Option>, + serializer: S, +) -> Result +where + T: Serialize, + S: Serializer, +{ + serde_with::rust::double_option::serialize(value, serializer) +} diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index 719268e9c5..ed1ca86229 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -5,6 +5,7 @@ use codex_protocol::ThreadId; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; +use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::Verbosity; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ReasoningEffort; @@ -419,6 +420,13 @@ pub struct SendUserTurnParams { pub approval_policy: AskForApproval, pub sandbox_policy: SandboxPolicy, pub model: String, + #[serde( + default, + deserialize_with = "super::serde_helpers::deserialize_double_option", + serialize_with = "super::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + pub service_tier: Option>, pub effort: Option, pub summary: ReasoningSummary, /// Optional JSON Schema used to constrain the final assistant message for this turn. @@ -429,6 +437,55 @@ pub struct SendUserTurnParams { #[serde(rename_all = "camelCase")] pub struct SendUserTurnResponse {} +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use std::path::PathBuf; + + #[test] + fn send_user_turn_params_preserve_explicit_null_service_tier() { + let params = SendUserTurnParams { + conversation_id: ThreadId::new(), + items: vec![], + cwd: PathBuf::from("/tmp"), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: "gpt-4.1".to_string(), + service_tier: Some(None), + effort: None, + summary: ReasoningSummary::Auto, + output_schema: None, + }; + + let serialized = serde_json::to_value(¶ms).expect("params should serialize"); + assert_eq!( + serialized.get("serviceTier"), + Some(&serde_json::Value::Null) + ); + + let roundtrip: SendUserTurnParams = + serde_json::from_value(serialized).expect("params should deserialize"); + assert_eq!(roundtrip.service_tier, Some(None)); + + let without_override = SendUserTurnParams { + conversation_id: ThreadId::new(), + items: vec![], + cwd: PathBuf::from("/tmp"), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: "gpt-4.1".to_string(), + service_tier: None, + effort: None, + summary: ReasoningSummary::Auto, + output_schema: None, + }; + let serialized_without_override = + serde_json::to_value(&without_override).expect("params should serialize"); + assert_eq!(serialized_without_override.get("serviceTier"), None); + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct InterruptConversationParams { @@ -555,6 +612,7 @@ pub struct LoginChatGptCompleteNotification { pub struct SessionConfiguredNotification { pub session_id: ThreadId, pub model: String, + pub service_tier: Option, pub reasoning_effort: Option, pub history_log_id: u64, #[ts(type = "number")] diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index bde0e17fa3..8f0b37beef 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -17,6 +17,7 @@ use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode as CoreSandboxMode; +use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::Verbosity; use codex_protocol::config_types::WebSearchMode; use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent; @@ -392,6 +393,7 @@ pub struct ProfileV2 { pub model: Option, pub model_provider: Option, pub approval_policy: Option, + pub service_tier: Option, pub model_reasoning_effort: Option, pub model_reasoning_summary: Option, pub model_verbosity: Option, @@ -503,6 +505,7 @@ pub struct Config { pub model_reasoning_effort: Option, pub model_reasoning_summary: Option, pub model_verbosity: Option, + pub service_tier: Option, pub analytics: Option, #[experimental("config/read.apps")] #[serde(default)] @@ -1788,6 +1791,14 @@ pub struct ThreadStartParams { pub model: Option, #[ts(optional = nullable)] pub model_provider: Option, + #[serde( + default, + deserialize_with = "super::serde_helpers::deserialize_double_option", + serialize_with = "super::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional = nullable)] + pub service_tier: Option>, #[ts(optional = nullable)] pub cwd: Option, #[ts(optional = nullable)] @@ -1850,6 +1861,7 @@ pub struct ThreadStartResponse { pub thread: Thread, pub model: String, pub model_provider: String, + pub service_tier: Option, pub cwd: PathBuf, pub approval_policy: AskForApproval, pub sandbox: SandboxPolicy, @@ -1891,6 +1903,14 @@ pub struct ThreadResumeParams { pub model: Option, #[ts(optional = nullable)] pub model_provider: Option, + #[serde( + default, + deserialize_with = "super::serde_helpers::deserialize_double_option", + serialize_with = "super::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional = nullable)] + pub service_tier: Option>, #[ts(optional = nullable)] pub cwd: Option, #[ts(optional = nullable)] @@ -1919,6 +1939,7 @@ pub struct ThreadResumeResponse { pub thread: Thread, pub model: String, pub model_provider: String, + pub service_tier: Option, pub cwd: PathBuf, pub approval_policy: AskForApproval, pub sandbox: SandboxPolicy, @@ -1951,6 +1972,14 @@ pub struct ThreadForkParams { pub model: Option, #[ts(optional = nullable)] pub model_provider: Option, + #[serde( + default, + deserialize_with = "super::serde_helpers::deserialize_double_option", + serialize_with = "super::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional = nullable)] + pub service_tier: Option>, #[ts(optional = nullable)] pub cwd: Option, #[ts(optional = nullable)] @@ -1977,6 +2006,7 @@ pub struct ThreadForkResponse { pub thread: Thread, pub model: String, pub model_provider: String, + pub service_tier: Option, pub cwd: PathBuf, pub approval_policy: AskForApproval, pub sandbox: SandboxPolicy, @@ -2837,6 +2867,15 @@ pub struct TurnStartParams { /// Override the model for this turn and subsequent turns. #[ts(optional = nullable)] pub model: Option, + /// Override the service tier for this turn and subsequent turns. + #[serde( + default, + deserialize_with = "super::serde_helpers::deserialize_double_option", + serialize_with = "super::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional = nullable)] + pub service_tier: Option>, /// Override the reasoning effort for this turn and subsequent turns. #[ts(optional = nullable)] pub effort: Option, @@ -4566,4 +4605,56 @@ mod tests { }) ); } + + #[test] + fn thread_start_params_preserve_explicit_null_service_tier() { + let params: ThreadStartParams = serde_json::from_value(json!({ "serviceTier": null })) + .expect("params should deserialize"); + assert_eq!(params.service_tier, Some(None)); + + let serialized = serde_json::to_value(¶ms).expect("params should serialize"); + assert_eq!( + serialized.get("serviceTier"), + Some(&serde_json::Value::Null) + ); + + let serialized_without_override = + serde_json::to_value(ThreadStartParams::default()).expect("params should serialize"); + assert_eq!(serialized_without_override.get("serviceTier"), None); + } + + #[test] + fn turn_start_params_preserve_explicit_null_service_tier() { + let params: TurnStartParams = serde_json::from_value(json!({ + "threadId": "thread_123", + "input": [], + "serviceTier": null + })) + .expect("params should deserialize"); + assert_eq!(params.service_tier, Some(None)); + + let serialized = serde_json::to_value(¶ms).expect("params should serialize"); + assert_eq!( + serialized.get("serviceTier"), + Some(&serde_json::Value::Null) + ); + + let without_override = TurnStartParams { + thread_id: "thread_123".to_string(), + input: vec![], + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: None, + service_tier: None, + effort: None, + summary: None, + output_schema: None, + collaboration_mode: None, + personality: None, + }; + let serialized_without_override = + serde_json::to_value(&without_override).expect("params should serialize"); + assert_eq!(serialized_without_override.get("serviceTier"), None); + } } diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index 0454d0522a..d8f06be421 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -15,6 +15,7 @@ use std::process::Command; use std::process::Stdio; use std::thread; use std::time::Duration; +use std::time::SystemTime; use anyhow::Context; use anyhow::Result; @@ -71,6 +72,7 @@ use codex_app_server_protocol::UserInput as V2UserInput; use codex_protocol::ThreadId; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::W3cTraceContext; use serde::Serialize; use serde::de::DeserializeOwned; use serde_json::Value; @@ -104,6 +106,8 @@ const NOTIFICATIONS_TO_OPT_OUT: &[&str] = &[ "item/reasoning/summaryTextDelta", "item/reasoning/textDelta", ]; +const APP_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5); +const APP_SERVER_GRACEFUL_SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(100); /// Minimal launcher that initializes the Codex app-server and logs the handshake. #[derive(Parser)] @@ -498,25 +502,26 @@ fn send_message( config_overrides: &[String], user_message: String, ) -> Result<()> { - let mut client = CodexClient::connect(endpoint, config_overrides)?; + with_client(endpoint, config_overrides, |client| { + let initialize = client.initialize()?; + println!("< initialize response: {initialize:?}"); - let initialize = client.initialize()?; - println!("< initialize response: {initialize:?}"); + let conversation = client.start_thread()?; + println!("< newConversation response: {conversation:?}"); - let conversation = client.start_thread()?; - println!("< newConversation response: {conversation:?}"); + let subscription = client.add_conversation_listener(&conversation.conversation_id)?; + println!("< addConversationListener response: {subscription:?}"); - let subscription = client.add_conversation_listener(&conversation.conversation_id)?; - println!("< addConversationListener response: {subscription:?}"); + let send_response = + client.send_user_message(&conversation.conversation_id, &user_message)?; + println!("< sendUserMessage response: {send_response:?}"); - let send_response = client.send_user_message(&conversation.conversation_id, &user_message)?; - println!("< sendUserMessage response: {send_response:?}"); + client.stream_conversation(&conversation.conversation_id)?; - client.stream_conversation(&conversation.conversation_id)?; + client.remove_thread_listener(subscription.subscription_id)?; - client.remove_thread_listener(subscription.subscription_id)?; - - Ok(()) + Ok(()) + }) } pub fn send_message_v2( @@ -574,82 +579,85 @@ fn trigger_zsh_fork_multi_cmd_approval( let default_prompt = "Run this exact command using shell command execution without rewriting or splitting it: /usr/bin/true && /usr/bin/true"; let message = user_message.unwrap_or_else(|| default_prompt.to_string()); - let mut client = CodexClient::connect(endpoint, config_overrides)?; - let initialize = client.initialize()?; - println!("< initialize response: {initialize:?}"); + with_client(endpoint, config_overrides, |client| { + let initialize = client.initialize()?; + println!("< initialize response: {initialize:?}"); - let thread_response = client.thread_start(ThreadStartParams { - dynamic_tools: dynamic_tools.clone(), - ..Default::default() - })?; - println!("< thread/start response: {thread_response:?}"); + let thread_response = client.thread_start(ThreadStartParams { + dynamic_tools: dynamic_tools.clone(), + ..Default::default() + })?; + println!("< thread/start response: {thread_response:?}"); - client.command_approval_behavior = match abort_on { - Some(index) => CommandApprovalBehavior::AbortOn(index), - None => CommandApprovalBehavior::AlwaysAccept, - }; - client.command_approval_count = 0; - client.command_approval_item_ids.clear(); - client.command_execution_statuses.clear(); - client.last_turn_status = None; + client.command_approval_behavior = match abort_on { + Some(index) => CommandApprovalBehavior::AbortOn(index), + None => CommandApprovalBehavior::AlwaysAccept, + }; + client.command_approval_count = 0; + client.command_approval_item_ids.clear(); + client.command_execution_statuses.clear(); + client.last_turn_status = None; - let mut turn_params = TurnStartParams { - thread_id: thread_response.thread.id.clone(), - input: vec![V2UserInput::Text { - text: message, - text_elements: Vec::new(), - }], - ..Default::default() - }; - turn_params.approval_policy = Some(AskForApproval::OnRequest); - turn_params.sandbox_policy = Some(SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::FullAccess, - }); + let mut turn_params = TurnStartParams { + thread_id: thread_response.thread.id.clone(), + input: vec![V2UserInput::Text { + text: message, + text_elements: Vec::new(), + }], + ..Default::default() + }; + turn_params.approval_policy = Some(AskForApproval::OnRequest); + turn_params.sandbox_policy = Some(SandboxPolicy::ReadOnly { + access: ReadOnlyAccess::FullAccess, + }); - let turn_response = client.turn_start(turn_params)?; - println!("< turn/start response: {turn_response:?}"); - client.stream_turn(&thread_response.thread.id, &turn_response.turn.id)?; + let turn_response = client.turn_start(turn_params)?; + println!("< turn/start response: {turn_response:?}"); + client.stream_turn(&thread_response.thread.id, &turn_response.turn.id)?; - if client.command_approval_count < min_approvals { - bail!( - "expected at least {min_approvals} command approvals, got {}", - client.command_approval_count - ); - } - let mut approvals_per_item = std::collections::BTreeMap::new(); - for item_id in &client.command_approval_item_ids { - *approvals_per_item.entry(item_id.clone()).or_insert(0usize) += 1; - } - let max_approvals_for_one_item = approvals_per_item.values().copied().max().unwrap_or(0); - if max_approvals_for_one_item < min_approvals { - bail!( - "expected at least {min_approvals} approvals for one command item, got max {max_approvals_for_one_item} with map {approvals_per_item:?}" - ); - } - - let last_command_status = client.command_execution_statuses.last(); - if abort_on.is_none() { - if last_command_status != Some(&CommandExecutionStatus::Completed) { - bail!("expected completed command execution, got {last_command_status:?}"); - } - if client.last_turn_status != Some(TurnStatus::Completed) { + if client.command_approval_count < min_approvals { bail!( - "expected completed turn in all-accept flow, got {:?}", - client.last_turn_status + "expected at least {min_approvals} command approvals, got {}", + client.command_approval_count ); } - } else if last_command_status == Some(&CommandExecutionStatus::Completed) { - bail!( - "expected non-completed command execution in mixed approval/decline flow, got {last_command_status:?}" + let mut approvals_per_item = std::collections::BTreeMap::new(); + for item_id in &client.command_approval_item_ids { + *approvals_per_item.entry(item_id.clone()).or_insert(0usize) += 1; + } + let max_approvals_for_one_item = approvals_per_item.values().copied().max().unwrap_or(0); + if max_approvals_for_one_item < min_approvals { + bail!( + "expected at least {min_approvals} approvals for one command item, got max {max_approvals_for_one_item} with map {approvals_per_item:?}" + ); + } + + let last_command_status = client.command_execution_statuses.last(); + if abort_on.is_none() { + if last_command_status != Some(&CommandExecutionStatus::Completed) { + bail!("expected completed command execution, got {last_command_status:?}"); + } + if client.last_turn_status != Some(TurnStatus::Completed) { + bail!( + "expected completed turn in all-accept flow, got {:?}", + client.last_turn_status + ); + } + } else if last_command_status == Some(&CommandExecutionStatus::Completed) { + bail!( + "expected non-completed command execution in mixed approval/decline flow, got {last_command_status:?}" + ); + } + + println!( + "[zsh-fork multi-approval summary] approvals={}, approvals_per_item={approvals_per_item:?}, command_statuses={:?}, turn_status={:?}", + client.command_approval_count, + client.command_execution_statuses, + client.last_turn_status ); - } - println!( - "[zsh-fork multi-approval summary] approvals={}, approvals_per_item={approvals_per_item:?}, command_statuses={:?}, turn_status={:?}", - client.command_approval_count, client.command_execution_statuses, client.last_turn_status - ); - - Ok(()) + Ok(()) + }) } fn resume_message_v2( @@ -661,30 +669,30 @@ fn resume_message_v2( ) -> Result<()> { ensure_dynamic_tools_unused(dynamic_tools, "resume-message-v2")?; - let mut client = CodexClient::connect(endpoint, config_overrides)?; + with_client(endpoint, config_overrides, |client| { + let initialize = client.initialize()?; + println!("< initialize response: {initialize:?}"); - let initialize = client.initialize()?; - println!("< initialize response: {initialize:?}"); + let resume_response = client.thread_resume(ThreadResumeParams { + thread_id, + ..Default::default() + })?; + println!("< thread/resume response: {resume_response:?}"); - let resume_response = client.thread_resume(ThreadResumeParams { - thread_id, - ..Default::default() - })?; - println!("< thread/resume response: {resume_response:?}"); + let turn_response = client.turn_start(TurnStartParams { + thread_id: resume_response.thread.id.clone(), + input: vec![V2UserInput::Text { + text: user_message, + text_elements: Vec::new(), + }], + ..Default::default() + })?; + println!("< turn/start response: {turn_response:?}"); - let turn_response = client.turn_start(TurnStartParams { - thread_id: resume_response.thread.id.clone(), - input: vec![V2UserInput::Text { - text: user_message, - text_elements: Vec::new(), - }], - ..Default::default() - })?; - println!("< turn/start response: {turn_response:?}"); + client.stream_turn(&resume_response.thread.id, &turn_response.turn.id)?; - client.stream_turn(&resume_response.thread.id, &turn_response.turn.id)?; - - Ok(()) + Ok(()) + }) } fn thread_resume_follow( @@ -787,34 +795,34 @@ fn send_message_v2_with_policies( sandbox_policy: Option, dynamic_tools: &Option>, ) -> Result<()> { - let mut client = CodexClient::connect(endpoint, config_overrides)?; + with_client(endpoint, config_overrides, |client| { + let initialize = client.initialize_with_experimental_api(experimental_api)?; + println!("< initialize response: {initialize:?}"); - let initialize = client.initialize_with_experimental_api(experimental_api)?; - println!("< initialize response: {initialize:?}"); + let thread_response = client.thread_start(ThreadStartParams { + dynamic_tools: dynamic_tools.clone(), + ..Default::default() + })?; + println!("< thread/start response: {thread_response:?}"); + let mut turn_params = TurnStartParams { + thread_id: thread_response.thread.id.clone(), + input: vec![V2UserInput::Text { + text: user_message, + // Test client sends plain text without UI element ranges. + text_elements: Vec::new(), + }], + ..Default::default() + }; + turn_params.approval_policy = approval_policy; + turn_params.sandbox_policy = sandbox_policy; - let thread_response = client.thread_start(ThreadStartParams { - dynamic_tools: dynamic_tools.clone(), - ..Default::default() - })?; - println!("< thread/start response: {thread_response:?}"); - let mut turn_params = TurnStartParams { - thread_id: thread_response.thread.id.clone(), - input: vec![V2UserInput::Text { - text: user_message, - // Test client sends plain text without UI element ranges. - text_elements: Vec::new(), - }], - ..Default::default() - }; - turn_params.approval_policy = approval_policy; - turn_params.sandbox_policy = sandbox_policy; + let turn_response = client.turn_start(turn_params)?; + println!("< turn/start response: {turn_response:?}"); - let turn_response = client.turn_start(turn_params)?; - println!("< turn/start response: {turn_response:?}"); + client.stream_turn(&thread_response.thread.id, &turn_response.turn.id)?; - client.stream_turn(&thread_response.thread.id, &turn_response.turn.id)?; - - Ok(()) + Ok(()) + }) } fn send_follow_up_v2( @@ -824,119 +832,130 @@ fn send_follow_up_v2( follow_up_message: String, dynamic_tools: &Option>, ) -> Result<()> { - let mut client = CodexClient::connect(endpoint, config_overrides)?; + with_client(endpoint, config_overrides, |client| { + let initialize = client.initialize()?; + println!("< initialize response: {initialize:?}"); - let initialize = client.initialize()?; - println!("< initialize response: {initialize:?}"); + let thread_response = client.thread_start(ThreadStartParams { + dynamic_tools: dynamic_tools.clone(), + ..Default::default() + })?; + println!("< thread/start response: {thread_response:?}"); - let thread_response = client.thread_start(ThreadStartParams { - dynamic_tools: dynamic_tools.clone(), - ..Default::default() - })?; - println!("< thread/start response: {thread_response:?}"); + let first_turn_params = TurnStartParams { + thread_id: thread_response.thread.id.clone(), + input: vec![V2UserInput::Text { + text: first_message, + // Test client sends plain text without UI element ranges. + text_elements: Vec::new(), + }], + ..Default::default() + }; + let first_turn_response = client.turn_start(first_turn_params)?; + println!("< turn/start response (initial): {first_turn_response:?}"); + client.stream_turn(&thread_response.thread.id, &first_turn_response.turn.id)?; - let first_turn_params = TurnStartParams { - thread_id: thread_response.thread.id.clone(), - input: vec![V2UserInput::Text { - text: first_message, - // Test client sends plain text without UI element ranges. - text_elements: Vec::new(), - }], - ..Default::default() - }; - let first_turn_response = client.turn_start(first_turn_params)?; - println!("< turn/start response (initial): {first_turn_response:?}"); - client.stream_turn(&thread_response.thread.id, &first_turn_response.turn.id)?; + let follow_up_params = TurnStartParams { + thread_id: thread_response.thread.id.clone(), + input: vec![V2UserInput::Text { + text: follow_up_message, + // Test client sends plain text without UI element ranges. + text_elements: Vec::new(), + }], + ..Default::default() + }; + let follow_up_response = client.turn_start(follow_up_params)?; + println!("< turn/start response (follow-up): {follow_up_response:?}"); + client.stream_turn(&thread_response.thread.id, &follow_up_response.turn.id)?; - let follow_up_params = TurnStartParams { - thread_id: thread_response.thread.id.clone(), - input: vec![V2UserInput::Text { - text: follow_up_message, - // Test client sends plain text without UI element ranges. - text_elements: Vec::new(), - }], - ..Default::default() - }; - let follow_up_response = client.turn_start(follow_up_params)?; - println!("< turn/start response (follow-up): {follow_up_response:?}"); - client.stream_turn(&thread_response.thread.id, &follow_up_response.turn.id)?; - - Ok(()) + Ok(()) + }) } fn test_login(endpoint: &Endpoint, config_overrides: &[String]) -> Result<()> { - let mut client = CodexClient::connect(endpoint, config_overrides)?; + with_client(endpoint, config_overrides, |client| { + let initialize = client.initialize()?; + println!("< initialize response: {initialize:?}"); - let initialize = client.initialize()?; - println!("< initialize response: {initialize:?}"); - - let login_response = client.login_chat_gpt()?; - println!("< loginChatGpt response: {login_response:?}"); - println!( - "Open the following URL in your browser to continue:\n{}", - login_response.auth_url - ); - - let completion = client.wait_for_login_completion(&login_response.login_id)?; - println!("< loginChatGptComplete notification: {completion:?}"); - - if completion.success { - println!("Login succeeded."); - Ok(()) - } else { - bail!( - "login failed: {}", - completion - .error - .as_deref() - .unwrap_or("unknown error from loginChatGptComplete") + let login_response = client.login_chat_gpt()?; + println!("< loginChatGpt response: {login_response:?}"); + println!( + "Open the following URL in your browser to continue:\n{}", + login_response.auth_url ); - } + + let completion = client.wait_for_login_completion(&login_response.login_id)?; + println!("< loginChatGptComplete notification: {completion:?}"); + + if completion.success { + println!("Login succeeded."); + Ok(()) + } else { + bail!( + "login failed: {}", + completion + .error + .as_deref() + .unwrap_or("unknown error from loginChatGptComplete") + ); + } + }) } fn get_account_rate_limits(endpoint: &Endpoint, config_overrides: &[String]) -> Result<()> { - let mut client = CodexClient::connect(endpoint, config_overrides)?; + with_client(endpoint, config_overrides, |client| { + let initialize = client.initialize()?; + println!("< initialize response: {initialize:?}"); - let initialize = client.initialize()?; - println!("< initialize response: {initialize:?}"); + let response = client.get_account_rate_limits()?; + println!("< account/rateLimits/read response: {response:?}"); - let response = client.get_account_rate_limits()?; - println!("< account/rateLimits/read response: {response:?}"); - - Ok(()) + Ok(()) + }) } fn model_list(endpoint: &Endpoint, config_overrides: &[String]) -> Result<()> { - let mut client = CodexClient::connect(endpoint, config_overrides)?; + with_client(endpoint, config_overrides, |client| { + let initialize = client.initialize()?; + println!("< initialize response: {initialize:?}"); - let initialize = client.initialize()?; - println!("< initialize response: {initialize:?}"); + let response = client.model_list(ModelListParams::default())?; + println!("< model/list response: {response:?}"); - let response = client.model_list(ModelListParams::default())?; - println!("< model/list response: {response:?}"); - - Ok(()) + Ok(()) + }) } fn thread_list(endpoint: &Endpoint, config_overrides: &[String], limit: u32) -> Result<()> { + with_client(endpoint, config_overrides, |client| { + let initialize = client.initialize()?; + println!("< initialize response: {initialize:?}"); + + let response = client.thread_list(ThreadListParams { + cursor: None, + limit: Some(limit), + sort_key: None, + model_providers: None, + source_kinds: None, + archived: None, + cwd: None, + search_term: None, + })?; + println!("< thread/list response: {response:?}"); + + Ok(()) + }) +} + +fn with_client( + endpoint: &Endpoint, + config_overrides: &[String], + f: impl FnOnce(&mut CodexClient) -> Result, +) -> Result { let mut client = CodexClient::connect(endpoint, config_overrides)?; - - let initialize = client.initialize()?; - println!("< initialize response: {initialize:?}"); - - let response = client.thread_list(ThreadListParams { - cursor: None, - limit: Some(limit), - sort_key: None, - model_providers: None, - source_kinds: None, - archived: None, - cwd: None, - search_term: None, - })?; - println!("< thread/list response: {response:?}"); - - Ok(()) + let result = f(&mut client); + client.print_trace_summary(); + result } fn ensure_dynamic_tools_unused( @@ -993,6 +1012,8 @@ struct CodexClient { command_approval_item_ids: Vec, command_execution_statuses: Vec, last_turn_status: Option, + trace_id: String, + trace_root_span_id: String, } #[derive(Debug, Clone, Copy)] @@ -1052,6 +1073,8 @@ impl CodexClient { command_approval_item_ids: Vec::new(), command_execution_statuses: Vec::new(), last_turn_status: None, + trace_id: generate_trace_id(), + trace_root_span_id: generate_parent_span_id(), }) } @@ -1073,6 +1096,8 @@ impl CodexClient { command_approval_item_ids: Vec::new(), command_execution_statuses: Vec::new(), last_turn_status: None, + trace_id: generate_trace_id(), + trace_root_span_id: generate_parent_span_id(), }) } @@ -1438,12 +1463,32 @@ impl CodexClient { } fn write_request(&mut self, request: &ClientRequest) -> Result<()> { - let request_json = serde_json::to_string(request)?; - let request_pretty = serde_json::to_string_pretty(request)?; + let request = self.jsonrpc_request_with_trace(request)?; + let request_json = serde_json::to_string(&request)?; + let request_pretty = serde_json::to_string_pretty(&request)?; print_multiline_with_prefix("> ", &request_pretty); self.write_payload(&request_json) } + fn jsonrpc_request_with_trace(&self, request: &ClientRequest) -> Result { + let request_value = serde_json::to_value(request)?; + let mut request: JSONRPCRequest = serde_json::from_value(request_value) + .context("client request was not a valid JSON-RPC request")?; + request.trace = Some(W3cTraceContext { + traceparent: Some(format!( + "00-{}-{}-01", + self.trace_id, self.trace_root_span_id + )), + tracestate: None, + }); + Ok(request) + } + + fn print_trace_summary(&self) { + println!("\n[Datadog trace]"); + println!("go/trace/{}\n", self.trace_id); + } + fn wait_for_response(&mut self, request_id: RequestId, method: &str) -> Result where T: DeserializeOwned, @@ -1709,6 +1754,15 @@ impl CodexClient { } } +fn generate_trace_id() -> String { + Uuid::new_v4().simple().to_string() +} + +fn generate_parent_span_id() -> String { + let uuid = Uuid::new_v4().simple().to_string(); + uuid[..16].to_string() +} + fn print_multiline_with_prefix(prefix: &str, payload: &str) { for line in payload.lines() { println!("{prefix}{line}"); @@ -1728,11 +1782,18 @@ impl Drop for CodexClient { return; } - thread::sleep(Duration::from_millis(100)); + let deadline = SystemTime::now() + APP_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT; + loop { + if let Ok(Some(status)) = child.try_wait() { + println!("[codex app-server exited: {status}]"); + return; + } - if let Ok(Some(status)) = child.try_wait() { - println!("[codex app-server exited: {status}]"); - return; + if SystemTime::now() >= deadline { + break; + } + + thread::sleep(APP_SERVER_GRACEFUL_SHUTDOWN_POLL_INTERVAL); } let _ = child.kill(); diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index eebaaba107..8a4ec05fd3 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -21,6 +21,7 @@ async-trait = { workspace = true } codex-arg0 = { workspace = true } codex-cloud-requirements = { workspace = true } codex-core = { workspace = true } +codex-otel = { workspace = true } codex-shell-command = { workspace = true } codex-utils-cli = { workspace = true } codex-backend-client = { workspace = true } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 64d0625c8d..9ec4821e8d 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -63,7 +63,7 @@ Use the thread APIs to create, list, or archive conversations. Drive a conversat - Initialize once per connection: Immediately after opening a transport connection, send an `initialize` request with your client metadata, then emit an `initialized` notification. Any other request on that connection before this handshake gets rejected. - Start (or resume) a thread: Call `thread/start` to open a fresh conversation. The response returns the thread object and you’ll also get a `thread/started` notification. If you’re continuing an existing conversation, call `thread/resume` with its ID instead. If you want to branch from an existing conversation, call `thread/fork` to create a new thread id with copied history. The returned `thread.ephemeral` flag tells you whether the session is intentionally in-memory only; when it is `true`, `thread.path` is `null`. -- Begin a turn: To send user input, call `turn/start` with the target `threadId` and the user's input. Optional fields let you override model, cwd, sandbox policy, etc. This immediately returns the new turn object and triggers a `turn/started` notification. +- Begin a turn: To send user input, call `turn/start` with the target `threadId` and the user's input. Optional fields let you override model, cwd, sandbox policy, etc. This immediately returns the new turn object. The app-server emits `turn/started` when that turn actually begins running. - Stream events: After `turn/start`, keep reading JSON-RPC notifications on stdout. You’ll see `item/started`, `item/completed`, deltas like `item/agentMessage/delta`, tool progress, etc. These represent streaming model output plus any side effects (commands, tool calls, reasoning notes). - Finish the turn: When the model is done (or the turn is interrupted via making the `turn/interrupt` call), the server sends `turn/completed` with the final turn state and token usage. @@ -120,16 +120,16 @@ Example with notification opt-out: ## API Overview -- `thread/start` — create a new thread; emits `thread/started` and auto-subscribes you to turn/item events for that thread. +- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. - `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. -- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; emits `thread/started` and auto-subscribes you to turn/item events for the new thread. +- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for the new thread. - `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. - `thread/loaded/list` — list the thread ids currently loaded in memory. - `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. - `thread/status/changed` — notification emitted when a loaded thread’s status changes (`threadId` + new `status`). - `thread/archive` — move a thread’s rollout file into the archived directory; returns `{}` on success and emits `thread/archived`. - `thread/unsubscribe` — unsubscribe this connection from thread turn/item events. If this was the last subscriber, the server shuts down and unloads the thread, then emits `thread/closed`. -- `thread/name/set` — set or update a thread’s user-facing name; returns `{}` on success. Thread names are not required to be unique; name lookups resolve to the most recently updated thread. +- `thread/name/set` — set or update a thread’s user-facing name for either a loaded thread or a persisted rollout; returns `{}` on success. Thread names are not required to be unique; name lookups resolve to the most recently updated thread. - `thread/unarchive` — move an archived rollout file back into the sessions directory; returns the restored `thread` on success and emits `thread/unarchived`. - `thread/compact/start` — trigger conversation history compaction for a thread; returns `{}` immediately while progress streams through standard turn/item notifications. - `thread/backgroundTerminals/clean` — terminate all running background terminals for a thread (experimental; requires `capabilities.experimentalApi`); returns `{}` when the cleanup request is accepted. @@ -273,10 +273,11 @@ When `nextCursor` is `null`, you’ve reached the final page. ### Example: Track thread status changes -`thread/status/changed` is emitted whenever a loaded thread's status changes: +`thread/status/changed` is emitted whenever a loaded thread's status changes after it has already been introduced to the client: - Includes `threadId` and the new `status`. - Status can be `notLoaded`, `idle`, `systemError`, or `active` (with `activeFlags`; `active` implies running). +- `thread/start`, `thread/fork`, and detached review threads do not emit a separate initial `thread/status/changed`; their `thread/started` notification already carries the current `thread.status`. ```json { "method": "thread/status/changed", "params": { @@ -620,7 +621,7 @@ Because audio is intentionally separate from `ThreadItem`, clients can opt out o ### Turn events -The app-server streams JSON-RPC notifications while a turn is running. Each turn starts with `turn/started` (initial `turn`) and ends with `turn/completed` (final `turn` status). Token usage events stream separately via `thread/tokenUsage/updated`. Clients subscribe to the events they care about, rendering each item incrementally as updates arrive. The per-item lifecycle is always: `item/started` → zero or more item-specific deltas → `item/completed`. +The app-server streams JSON-RPC notifications while a turn is running. Each turn emits `turn/started` when it begins running and ends with `turn/completed` (final `turn` status). Token usage events stream separately via `thread/tokenUsage/updated`. Clients subscribe to the events they care about, rendering each item incrementally as updates arrive. The per-item lifecycle is always: `item/started` → zero or more item-specific deltas → `item/completed`. - `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?, additionalDetails? } }`. diff --git a/codex-rs/app-server/src/app_server_tracing.rs b/codex-rs/app-server/src/app_server_tracing.rs new file mode 100644 index 0000000000..779cef9671 --- /dev/null +++ b/codex-rs/app-server/src/app_server_tracing.rs @@ -0,0 +1,101 @@ +use crate::message_processor::ConnectionSessionState; +use crate::outgoing_message::ConnectionId; +use crate::transport::AppServerTransport; +use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCRequest; +use codex_otel::set_parent_from_context; +use codex_otel::set_parent_from_w3c_trace_context; +use codex_otel::traceparent_context_from_env; +use codex_protocol::protocol::W3cTraceContext; +use tracing::Span; +use tracing::field; +use tracing::info_span; + +pub(crate) fn request_span( + request: &JSONRPCRequest, + transport: AppServerTransport, + connection_id: ConnectionId, + session: &ConnectionSessionState, +) -> Span { + let span = info_span!( + "app_server.request", + otel.kind = "server", + otel.name = request.method.as_str(), + rpc.system = "jsonrpc", + rpc.method = request.method.as_str(), + rpc.transport = transport_name(transport), + rpc.request_id = ?request.id, + app_server.connection_id = ?connection_id, + app_server.api_version = "v2", + app_server.client_name = field::Empty, + app_server.client_version = field::Empty, + ); + + let initialize_client_info = initialize_client_info(request); + if let Some(client_name) = client_name(initialize_client_info.as_ref(), session) { + span.record("app_server.client_name", client_name); + } + if let Some(client_version) = client_version(initialize_client_info.as_ref(), session) { + span.record("app_server.client_version", client_version); + } + + if let Some(traceparent) = request + .trace + .as_ref() + .and_then(|trace| trace.traceparent.as_deref()) + { + let trace = W3cTraceContext { + traceparent: Some(traceparent.to_string()), + tracestate: request + .trace + .as_ref() + .and_then(|value| value.tracestate.clone()), + }; + if !set_parent_from_w3c_trace_context(&span, &trace) { + tracing::warn!( + rpc_method = request.method.as_str(), + rpc_request_id = ?request.id, + "ignoring invalid inbound request trace carrier" + ); + } + } else if let Some(context) = traceparent_context_from_env() { + set_parent_from_context(&span, context); + } + + span +} + +fn transport_name(transport: AppServerTransport) -> &'static str { + match transport { + AppServerTransport::Stdio => "stdio", + AppServerTransport::WebSocket { .. } => "websocket", + } +} + +fn client_name<'a>( + initialize_client_info: Option<&'a InitializeParams>, + session: &'a ConnectionSessionState, +) -> Option<&'a str> { + if let Some(params) = initialize_client_info { + return Some(params.client_info.name.as_str()); + } + session.app_server_client_name.as_deref() +} + +fn client_version<'a>( + initialize_client_info: Option<&'a InitializeParams>, + session: &'a ConnectionSessionState, +) -> Option<&'a str> { + if let Some(params) = initialize_client_info { + return Some(params.client_info.version.as_str()); + } + session.client_version.as_deref() +} + +fn initialize_client_info(request: &JSONRPCRequest) -> Option { + if request.method != "initialize" { + return None; + } + let params = request.params.clone()?; + serde_json::from_value(params).ok() +} diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 35725e7a89..2f293c9347 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -83,6 +83,7 @@ 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::TurnStartedNotification; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::build_turns_from_rollout_items; use codex_app_server_protocol::convert_patch_changes; @@ -185,12 +186,30 @@ pub(crate) async fn apply_bespoke_event_handling( msg, } = event; match msg { - EventMsg::TurnStarted(_) => { + EventMsg::TurnStarted(payload) => { // While not technically necessary as it was already done on TurnComplete, be extra cautios and abort any pending server requests. outgoing.abort_pending_server_requests().await; thread_watch_manager .note_turn_started(&conversation_id.to_string()) .await; + if let ApiVersion::V2 = api_version { + let turn = { + let state = thread_state.lock().await; + state.active_turn_snapshot().unwrap_or_else(|| Turn { + id: payload.turn_id.clone(), + items: Vec::new(), + error: None, + status: TurnStatus::InProgress, + }) + }; + let notification = TurnStartedNotification { + thread_id: conversation_id.to_string(), + turn, + }; + outgoing + .send_server_notification(ServerNotification::TurnStarted(notification)) + .await; + } } EventMsg::TurnComplete(_ev) => { // All per-thread requests are bound to a turn, so abort them. @@ -232,7 +251,6 @@ pub(crate) async fn apply_bespoke_event_handling( EventMsg::RealtimeConversationRealtime(event) => { if let ApiVersion::V2 = api_version { match event.payload { - RealtimeEvent::SessionCreated { .. } => {} RealtimeEvent::SessionUpdated { .. } => {} RealtimeEvent::AudioOut(audio) => { let notification = ThreadRealtimeOutputAudioDeltaNotification { @@ -256,6 +274,24 @@ pub(crate) async fn apply_bespoke_event_handling( )) .await; } + RealtimeEvent::ConversationItemDone { .. } => {} + RealtimeEvent::HandoffRequested(handoff) => { + let notification = ThreadRealtimeItemAddedNotification { + thread_id: conversation_id.to_string(), + item: serde_json::json!({ + "type": "handoff_request", + "handoff_id": handoff.handoff_id, + "item_id": handoff.item_id, + "input_transcript": handoff.input_transcript, + "messages": handoff.messages, + }), + }; + outgoing + .send_server_notification(ServerNotification::ThreadRealtimeItemAdded( + notification, + )) + .await; + } RealtimeEvent::Error(message) => { let notification = ThreadRealtimeErrorNotification { thread_id: conversation_id.to_string(), diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index e65f7e888f..3c1c29ecab 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -141,6 +141,7 @@ use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadListResponse; use codex_app_server_protocol::ThreadLoadedListParams; use codex_app_server_protocol::ThreadLoadedListResponse; +use codex_app_server_protocol::ThreadNameUpdatedNotification; use codex_app_server_protocol::ThreadReadParams; use codex_app_server_protocol::ThreadReadResponse; use codex_app_server_protocol::ThreadRealtimeAppendAudioParams; @@ -172,7 +173,6 @@ use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnInterruptParams; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; -use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::TurnSteerParams; use codex_app_server_protocol::TurnSteerResponse; @@ -374,7 +374,6 @@ pub(crate) struct CodexMessageProcessor { outgoing: Arc, arg0_paths: Arg0DispatchPaths, config: Arc, - single_client_mode: bool, cli_overrides: Vec<(String, TomlValue)>, cloud_requirements: Arc>, active_login: Arc>>, @@ -401,7 +400,6 @@ struct ListenerTaskContext { thread_watch_manager: ThreadWatchManager, fallback_model_provider: String, codex_home: PathBuf, - single_client_mode: bool, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -418,7 +416,6 @@ pub(crate) struct CodexMessageProcessorArgs { pub(crate) config: Arc, pub(crate) cli_overrides: Vec<(String, TomlValue)>, pub(crate) cloud_requirements: Arc>, - pub(crate) single_client_mode: bool, pub(crate) feedback: CodexFeedback, } @@ -463,7 +460,6 @@ impl CodexMessageProcessor { config, cli_overrides, cloud_requirements, - single_client_mode, feedback, } = args; Self { @@ -472,7 +468,6 @@ impl CodexMessageProcessor { outgoing: outgoing.clone(), arg0_paths, config, - single_client_mode, cli_overrides, cloud_requirements, active_login: Arc::new(Mutex::new(None)), @@ -2047,6 +2042,7 @@ impl CodexMessageProcessor { let ThreadStartParams { model, model_provider, + service_tier, cwd, approval_policy, sandbox, @@ -2064,6 +2060,7 @@ impl CodexMessageProcessor { let mut typesafe_overrides = self.build_thread_config_overrides( model, model_provider, + service_tier, cwd, approval_policy, sandbox, @@ -2081,7 +2078,6 @@ impl CodexMessageProcessor { thread_watch_manager: self.thread_watch_manager.clone(), fallback_model_provider: self.config.model_provider_id.clone(), codex_home: self.config.codex_home.clone(), - single_client_mode: self.single_client_mode, }; tokio::spawn(async move { @@ -2204,7 +2200,7 @@ impl CodexMessageProcessor { listener_task_context .thread_watch_manager - .upsert_thread(thread.clone()) + .upsert_thread_silently(thread.clone()) .await; thread.status = resolve_thread_status( @@ -2219,6 +2215,7 @@ impl CodexMessageProcessor { thread: thread.clone(), model: config_snapshot.model, model_provider: config_snapshot.model_provider_id, + service_tier: config_snapshot.service_tier, cwd: config_snapshot.cwd, approval_policy: config_snapshot.approval_policy.into(), sandbox: config_snapshot.sandbox_policy.into(), @@ -2255,6 +2252,7 @@ impl CodexMessageProcessor { &self, model: Option, model_provider: Option, + service_tier: Option>, cwd: Option, approval_policy: Option, sandbox: Option, @@ -2265,6 +2263,7 @@ impl CodexMessageProcessor { ConfigOverrides { model, model_provider, + service_tier, cwd: cwd.map(PathBuf::from), approval_policy: approval_policy .map(codex_app_server_protocol::AskForApproval::to_core), @@ -2341,6 +2340,14 @@ impl CodexMessageProcessor { async fn thread_set_name(&self, request_id: ConnectionRequestId, params: ThreadSetNameParams) { let ThreadSetNameParams { thread_id, name } = params; + let thread_id = match ThreadId::from_string(&thread_id) { + Ok(id) => id, + Err(err) => { + self.send_invalid_request_error(request_id, format!("invalid thread id: {err}")) + .await; + return; + } + }; let Some(name) = codex_core::util::normalize_thread_name(&name) else { self.send_invalid_request_error( request_id, @@ -2350,15 +2357,43 @@ impl CodexMessageProcessor { return; }; - let (_, thread) = match self.load_thread(&thread_id).await { - Ok(v) => v, - Err(error) => { - self.outgoing.send_error(request_id, error).await; + if let Ok(thread) = self.thread_manager.get_thread(thread_id).await { + if let Err(err) = thread.submit(Op::SetThreadName { name }).await { + self.send_internal_error(request_id, format!("failed to set thread name: {err}")) + .await; return; } - }; - if let Err(err) = thread.submit(Op::SetThreadName { name }).await { + self.outgoing + .send_response(request_id, ThreadSetNameResponse {}) + .await; + return; + } + + let thread_exists = + match find_thread_path_by_id_str(&self.config.codex_home, &thread_id.to_string()).await + { + Ok(Some(_)) => true, + Ok(None) => false, + Err(err) => { + self.send_invalid_request_error( + request_id, + format!("failed to locate thread id {thread_id}: {err}"), + ) + .await; + return; + } + }; + + if !thread_exists { + self.send_invalid_request_error(request_id, format!("thread not found: {thread_id}")) + .await; + return; + } + + if let Err(err) = + codex_core::append_thread_name(&self.config.codex_home, thread_id, &name).await + { self.send_internal_error(request_id, format!("failed to set thread name: {err}")) .await; return; @@ -2367,6 +2402,13 @@ impl CodexMessageProcessor { self.outgoing .send_response(request_id, ThreadSetNameResponse {}) .await; + let notification = ThreadNameUpdatedNotification { + thread_id: thread_id.to_string(), + thread_name: Some(name), + }; + self.outgoing + .send_server_notification(ServerNotification::ThreadNameUpdated(notification)) + .await; } async fn thread_unarchive( @@ -3023,6 +3065,7 @@ impl CodexMessageProcessor { path, model, model_provider, + service_tier, cwd, approval_policy, sandbox, @@ -3055,6 +3098,7 @@ impl CodexMessageProcessor { let typesafe_overrides = self.build_thread_config_overrides( model, model_provider, + service_tier, cwd, approval_policy, sandbox, @@ -3153,6 +3197,7 @@ impl CodexMessageProcessor { thread, model: session_configured.model, model_provider: session_configured.model_provider_id, + service_tier: session_configured.service_tier, cwd: session_configured.cwd, approval_policy: session_configured.approval_policy.into(), sandbox: session_configured.sandbox_policy.into(), @@ -3460,6 +3505,7 @@ impl CodexMessageProcessor { path, model, model_provider, + service_tier, cwd, approval_policy, sandbox, @@ -3540,6 +3586,7 @@ impl CodexMessageProcessor { let typesafe_overrides = self.build_thread_config_overrides( model, model_provider, + service_tier, cwd, approval_policy, sandbox, @@ -3667,7 +3714,7 @@ impl CodexMessageProcessor { } self.thread_watch_manager - .upsert_thread(thread.clone()) + .upsert_thread_silently(thread.clone()) .await; thread.status = resolve_thread_status( @@ -3681,6 +3728,7 @@ impl CodexMessageProcessor { thread: thread.clone(), model: session_configured.model, model_provider: session_configured.model_provider_id, + service_tier: session_configured.service_tier, cwd: session_configured.cwd, approval_policy: session_configured.approval_policy.into(), sandbox: session_configured.sandbox_policy.into(), @@ -4589,6 +4637,7 @@ impl CodexMessageProcessor { SessionConfiguredNotification { session_id: session_configured.session_id, model: session_configured.model.clone(), + service_tier: session_configured.service_tier, reasoning_effort: session_configured.reasoning_effort, history_log_id: session_configured.history_log_id, history_entry_count: session_configured.history_entry_count, @@ -4810,6 +4859,7 @@ impl CodexMessageProcessor { SessionConfiguredNotification { session_id: session_configured.session_id, model: session_configured.model.clone(), + service_tier: session_configured.service_tier, reasoning_effort: session_configured.reasoning_effort, history_log_id: session_configured.history_log_id, history_entry_count: session_configured.history_entry_count, @@ -5237,6 +5287,7 @@ impl CodexMessageProcessor { approval_policy, sandbox_policy, model, + service_tier, effort, summary, output_schema, @@ -5286,6 +5337,7 @@ impl CodexMessageProcessor { model, effort, summary: Some(summary), + service_tier, final_output_json_schema: output_schema, collaboration_mode: None, personality: None, @@ -5827,6 +5879,7 @@ impl CodexMessageProcessor { || params.approval_policy.is_some() || params.sandbox_policy.is_some() || params.model.is_some() + || params.service_tier.is_some() || params.effort.is_some() || params.summary.is_some() || collaboration_mode.is_some() @@ -5843,6 +5896,7 @@ impl CodexMessageProcessor { model: params.model, effort: params.effort.map(Some), summary: params.summary, + service_tier: params.service_tier, collaboration_mode, personality: params.personality, }) @@ -5866,17 +5920,8 @@ impl CodexMessageProcessor { status: TurnStatus::InProgress, }; - let response = TurnStartResponse { turn: turn.clone() }; + let response = TurnStartResponse { turn }; self.outgoing.send_response(request_id, response).await; - - // Emit v2 turn/started notification. - let notif = TurnStartedNotification { - thread_id: params.thread_id, - turn, - }; - self.outgoing - .send_server_notification(ServerNotification::TurnStarted(notif)) - .await; } Err(err) => { let error = JSONRPCErrorError { @@ -6167,24 +6212,15 @@ impl CodexMessageProcessor { &self, request_id: &ConnectionRequestId, turn: Turn, - parent_thread_id: String, review_thread_id: String, ) { let response = ReviewStartResponse { - turn: turn.clone(), + turn, 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( @@ -6200,13 +6236,8 @@ impl CodexMessageProcessor { 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; + self.emit_review_started(request_id, turn, parent_thread_id) + .await; Ok(()) } Err(err) => Err(JSONRPCErrorError { @@ -6281,7 +6312,7 @@ impl CodexMessageProcessor { Ok(summary) => { let mut thread = summary_to_thread(summary); self.thread_watch_manager - .upsert_thread(thread.clone()) + .upsert_thread_silently(thread.clone()) .await; thread.status = resolve_thread_status( self.thread_watch_manager @@ -6320,7 +6351,7 @@ impl CodexMessageProcessor { let turn = Self::build_review_turn(turn_id, display_text); let review_thread_id = thread_id.to_string(); - self.emit_review_started(request_id, turn, review_thread_id.clone(), review_thread_id) + self.emit_review_started(request_id, turn, review_thread_id) .await; Ok(()) @@ -6496,7 +6527,6 @@ impl CodexMessageProcessor { thread_watch_manager: self.thread_watch_manager.clone(), fallback_model_provider: self.config.model_provider_id.clone(), codex_home: self.config.codex_home.clone(), - single_client_mode: self.single_client_mode, }, conversation_id, connection_id, @@ -6584,7 +6614,6 @@ impl CodexMessageProcessor { thread_watch_manager: self.thread_watch_manager.clone(), fallback_model_provider: self.config.model_provider_id.clone(), codex_home: self.config.codex_home.clone(), - single_client_mode: self.single_client_mode, }, conversation_id, conversation, @@ -6616,7 +6645,6 @@ impl CodexMessageProcessor { thread_watch_manager, fallback_model_provider, codex_home, - single_client_mode, } = listener_task_context; let outgoing_for_task = Arc::clone(&outgoing); tokio::spawn(async move { @@ -6666,9 +6694,7 @@ impl CodexMessageProcessor { ); let raw_events_enabled = { let mut thread_state = thread_state.lock().await; - if !single_client_mode { - thread_state.track_current_turn_event(&event.msg); - } + thread_state.track_current_turn_event(&event.msg); thread_state.experimental_raw_events }; let subscribed_connection_ids = thread_state_manager @@ -7142,6 +7168,7 @@ async fn handle_pending_thread_resume_request( let ThreadConfigSnapshot { model, model_provider_id, + service_tier, approval_policy, sandbox_policy, cwd, @@ -7152,6 +7179,7 @@ async fn handle_pending_thread_resume_request( thread, model, model_provider: model_provider_id, + service_tier, cwd, approval_policy: approval_policy.into(), sandbox: sandbox_policy.into(), diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index dcc438e292..a84712ea05 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -52,6 +52,7 @@ use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::registry::Registry; use tracing_subscriber::util::SubscriberInitExt; +mod app_server_tracing; mod bespoke_event_handling; mod codex_message_processor; mod config_api; @@ -447,7 +448,7 @@ pub async fn run_main_with_transport( let otel = codex_core::otel_init::build_provider( &config, env!("CARGO_PKG_VERSION"), - Some("codex_app_server"), + Some("codex-app-server"), default_analytics_enabled, ) .map_err(|e| { @@ -557,7 +558,6 @@ pub async fn run_main_with_transport( outgoing: outgoing_message_sender, arg0_paths, config: Arc::new(config), - single_client_mode, cli_overrides, loader_overrides, cloud_requirements: cloud_requirements.clone(), @@ -675,6 +675,7 @@ pub async fn run_main_with_transport( .process_request( connection_id, request, + transport, &mut connection_state.session, &connection_state.outbound_initialized, ) diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 2c3034aa1a..48f2f69e87 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -12,6 +12,7 @@ use crate::external_agent_config_api::ExternalAgentConfigApi; use crate::outgoing_message::ConnectionId; use crate::outgoing_message::ConnectionRequestId; use crate::outgoing_message::OutgoingMessageSender; +use crate::transport::AppServerTransport; use async_trait::async_trait; use codex_app_server_protocol::ChatgptAuthTokensRefreshParams; use codex_app_server_protocol::ChatgptAuthTokensRefreshReason; @@ -59,6 +60,7 @@ use tokio::sync::watch; use tokio::time::Duration; use tokio::time::timeout; use toml::Value as TomlValue; +use tracing::Instrument; const EXTERNAL_AUTH_REFRESH_TIMEOUT: Duration = Duration::from_secs(10); @@ -141,13 +143,13 @@ pub(crate) struct ConnectionSessionState { pub(crate) experimental_api_enabled: bool, pub(crate) opted_out_notification_methods: HashSet, pub(crate) app_server_client_name: Option, + pub(crate) client_version: Option, } pub(crate) struct MessageProcessorArgs { pub(crate) outgoing: Arc, pub(crate) arg0_paths: Arg0DispatchPaths, pub(crate) config: Arc, - pub(crate) single_client_mode: bool, pub(crate) cli_overrides: Vec<(String, TomlValue)>, pub(crate) loader_overrides: LoaderOverrides, pub(crate) cloud_requirements: CloudRequirementsLoader, @@ -163,7 +165,6 @@ impl MessageProcessor { outgoing, arg0_paths, config, - single_client_mode, cli_overrides, loader_overrides, cloud_requirements, @@ -199,7 +200,6 @@ impl MessageProcessor { config: Arc::clone(&config), cli_overrides: cli_overrides.clone(), cloud_requirements: cloud_requirements.clone(), - single_client_mode, feedback, }); let config_api = ConfigApi::new( @@ -224,46 +224,50 @@ impl MessageProcessor { &mut self, connection_id: ConnectionId, request: JSONRPCRequest, + transport: AppServerTransport, session: &mut ConnectionSessionState, outbound_initialized: &AtomicBool, ) { - let request_method = request.method.as_str(); - tracing::trace!( - ?connection_id, - request_id = ?request.id, - "app-server request: {request_method}" - ); - let request_id = ConnectionRequestId { - connection_id, - request_id: request.id.clone(), - }; - let request_json = match serde_json::to_value(&request) { - Ok(request_json) => request_json, - Err(err) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("Invalid request: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } - }; + let request_span = + crate::app_server_tracing::request_span(&request, transport, connection_id, session); + async { + let request_method = request.method.as_str(); + tracing::trace!( + ?connection_id, + request_id = ?request.id, + "app-server request: {request_method}" + ); + let request_id = ConnectionRequestId { + connection_id, + request_id: request.id.clone(), + }; + let request_json = match serde_json::to_value(&request) { + Ok(request_json) => request_json, + Err(err) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("Invalid request: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; - let codex_request = match serde_json::from_value::(request_json) { - Ok(codex_request) => codex_request, - Err(err) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("Invalid request: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } - }; + let codex_request = match serde_json::from_value::(request_json) { + Ok(codex_request) => codex_request, + Err(err) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("Invalid request: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; - match codex_request { + match codex_request { // Handle Initialize internally so CodexMessageProcessor does not have to concern // itself with the `initialized` bool. ClientRequest::Initialize { request_id, params } => { @@ -304,6 +308,8 @@ impl MessageProcessor { title: _title, version, } = params.client_info; + session.app_server_client_name = Some(name.clone()); + session.client_version = Some(version.clone()); if let Err(error) = set_default_originator(name.clone()) { match error { SetOriginatorError::InvalidHeaderValue => { @@ -330,7 +336,6 @@ impl MessageProcessor { if let Ok(mut suffix) = USER_AGENT_SUFFIX.lock() { *suffix = Some(user_agent_suffix); } - session.app_server_client_name = Some(name.clone()); let user_agent = get_codex_user_agent(); let response = InitializeResponse { user_agent }; @@ -355,91 +360,97 @@ impl MessageProcessor { return; } } - } + } + if let Some(reason) = codex_request.experimental_reason() + && !session.experimental_api_enabled + { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: experimental_required_message(reason), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } - if let Some(reason) = codex_request.experimental_reason() - && !session.experimental_api_enabled - { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: experimental_required_message(reason), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } - - match codex_request { - ClientRequest::ConfigRead { request_id, params } => { - self.handle_config_read( - ConnectionRequestId { - connection_id, - request_id, - }, - params, - ) - .await; - } - ClientRequest::ExternalAgentConfigDetect { request_id, params } => { - self.handle_external_agent_config_detect( - ConnectionRequestId { - connection_id, - request_id, - }, - params, - ) - .await; - } - ClientRequest::ExternalAgentConfigImport { request_id, params } => { - self.handle_external_agent_config_import( - ConnectionRequestId { - connection_id, - request_id, - }, - params, - ) - .await; - } - ClientRequest::ConfigValueWrite { request_id, params } => { - self.handle_config_value_write( - ConnectionRequestId { - connection_id, - request_id, - }, - params, - ) - .await; - } - ClientRequest::ConfigBatchWrite { request_id, params } => { - self.handle_config_batch_write( - ConnectionRequestId { - connection_id, - request_id, - }, - params, - ) - .await; - } - ClientRequest::ConfigRequirementsRead { - request_id, - params: _, - } => { - self.handle_config_requirements_read(ConnectionRequestId { - connection_id, - request_id, - }) - .await; - } - other => { - // Box the delegated future so this wrapper's async state machine does not - // inline the full `CodexMessageProcessor::process_request` future, which - // can otherwise push worker-thread stack usage over the edge. - self.codex_message_processor - .process_request(connection_id, other, session.app_server_client_name.clone()) - .boxed() + match codex_request { + ClientRequest::ConfigRead { request_id, params } => { + self.handle_config_read( + ConnectionRequestId { + connection_id, + request_id, + }, + params, + ) .await; + } + ClientRequest::ExternalAgentConfigDetect { request_id, params } => { + self.handle_external_agent_config_detect( + ConnectionRequestId { + connection_id, + request_id, + }, + params, + ) + .await; + } + ClientRequest::ExternalAgentConfigImport { request_id, params } => { + self.handle_external_agent_config_import( + ConnectionRequestId { + connection_id, + request_id, + }, + params, + ) + .await; + } + ClientRequest::ConfigValueWrite { request_id, params } => { + self.handle_config_value_write( + ConnectionRequestId { + connection_id, + request_id, + }, + params, + ) + .await; + } + ClientRequest::ConfigBatchWrite { request_id, params } => { + self.handle_config_batch_write( + ConnectionRequestId { + connection_id, + request_id, + }, + params, + ) + .await; + } + ClientRequest::ConfigRequirementsRead { + request_id, + params: _, + } => { + self.handle_config_requirements_read(ConnectionRequestId { + connection_id, + request_id, + }) + .await; + } + other => { + // Box the delegated future so this wrapper's async state machine does not + // inline the full `CodexMessageProcessor::process_request` future, which + // can otherwise push worker-thread stack usage over the edge. + self.codex_message_processor + .process_request( + connection_id, + other, + session.app_server_client_name.clone(), + ) + .boxed() + .await; + } } } + .instrument(request_span) + .await; } pub(crate) async fn process_notification(&self, notification: JSONRPCNotification) { diff --git a/codex-rs/app-server/src/thread_status.rs b/codex-rs/app-server/src/thread_status.rs index c5faa42d16..d366089a7c 100644 --- a/codex-rs/app-server/src/thread_status.rs +++ b/codex-rs/app-server/src/thread_status.rs @@ -91,7 +91,12 @@ impl ThreadWatchManager { } pub(crate) async fn upsert_thread(&self, thread: Thread) { - self.mutate_and_publish(move |state| state.upsert_thread(thread.id)) + self.mutate_and_publish(move |state| state.upsert_thread(thread.id, true)) + .await; + } + + pub(crate) async fn upsert_thread_silently(&self, thread: Thread) { + self.mutate_and_publish(move |state| state.upsert_thread(thread.id, false)) .await; } @@ -289,14 +294,22 @@ struct ThreadWatchState { } impl ThreadWatchState { - fn upsert_thread(&mut self, thread_id: String) -> Option { + fn upsert_thread( + &mut self, + thread_id: String, + emit_notification: bool, + ) -> Option { let previous_status = self.status_for(&thread_id); let runtime = self .runtime_by_thread_id .entry(thread_id.clone()) .or_default(); runtime.is_loaded = true; - self.status_changed_notification(thread_id, previous_status) + if emit_notification { + self.status_changed_notification(thread_id, previous_status) + } else { + None + } } fn remove_thread(&mut self, thread_id: &str) -> Option { @@ -692,6 +705,45 @@ mod tests { ); } + #[tokio::test] + async fn silent_upsert_skips_initial_notification() { + let (outgoing_tx, mut outgoing_rx) = mpsc::channel(8); + let manager = ThreadWatchManager::new_with_outgoing(Arc::new(OutgoingMessageSender::new( + outgoing_tx, + ))); + + manager + .upsert_thread_silently(test_thread( + INTERACTIVE_THREAD_ID, + codex_app_server_protocol::SessionSource::Cli, + )) + .await; + + assert_eq!( + manager + .loaded_status_for_thread(INTERACTIVE_THREAD_ID) + .await, + ThreadStatus::Idle, + ); + assert!( + timeout(Duration::from_millis(100), outgoing_rx.recv()) + .await + .is_err(), + "silent upsert should not emit thread/status/changed" + ); + + manager.note_turn_started(INTERACTIVE_THREAD_ID).await; + assert_eq!( + recv_status_changed_notification(&mut outgoing_rx).await, + ThreadStatusChangedNotification { + thread_id: INTERACTIVE_THREAD_ID.to_string(), + status: ThreadStatus::Active { + active_flags: vec![], + }, + }, + ); + } + async fn wait_for_status( manager: &ThreadWatchManager, thread_id: &str, diff --git a/codex-rs/app-server/src/transport.rs b/codex-rs/app-server/src/transport.rs index 2bae5b44e9..019d00e45e 100644 --- a/codex-rs/app-server/src/transport.rs +++ b/codex-rs/app-server/src/transport.rs @@ -744,6 +744,7 @@ mod tests { id: codex_app_server_protocol::RequestId::Integer(7), method: "config/read".to_string(), params: Some(json!({ "includeLayers": false })), + trace: None, }); assert!( enqueue_incoming_message(&transport_event_tx, &writer_tx, connection_id, request).await @@ -885,6 +886,7 @@ mod tests { id: codex_app_server_protocol::RequestId::Integer(7), method: "config/read".to_string(), params: Some(json!({ "includeLayers": false })), + trace: None, }); let enqueue_result = tokio::time::timeout( diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index cb0c98806f..9f0fb31cb4 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -891,6 +891,7 @@ impl McpProcess { id: RequestId::Integer(request_id), method: method.to_string(), params, + trace: None, }); self.send_jsonrpc_message(message).await?; Ok(request_id) diff --git a/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs b/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs index f6d3b9efb9..b5176886bd 100644 --- a/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs +++ b/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs @@ -36,7 +36,7 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; -const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20); +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(45); #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn test_codex_jsonrpc_conversation_flow() -> Result<()> { @@ -337,6 +337,7 @@ async fn test_send_user_turn_changes_approval_policy_behavior() -> Result<()> { model: "mock-model".to_string(), effort: Some(ReasoningEffort::Medium), summary: ReasoningSummary::Auto, + service_tier: None, output_schema: None, }) .await?; @@ -453,6 +454,7 @@ async fn test_send_user_turn_updates_sandbox_and_cwd_between_turns() -> Result<( model: model.clone(), effort: Some(ReasoningEffort::Medium), summary: ReasoningSummary::Auto, + service_tier: None, output_schema: None, }) .await?; @@ -481,6 +483,7 @@ async fn test_send_user_turn_updates_sandbox_and_cwd_between_turns() -> Result<( model: model.clone(), effort: Some(ReasoningEffort::Medium), summary: ReasoningSummary::Auto, + service_tier: None, output_schema: None, }) .await?; diff --git a/codex-rs/app-server/tests/suite/output_schema.rs b/codex-rs/app-server/tests/suite/output_schema.rs index db006e3284..b182e4262e 100644 --- a/codex-rs/app-server/tests/suite/output_schema.rs +++ b/codex-rs/app-server/tests/suite/output_schema.rs @@ -92,6 +92,7 @@ async fn send_user_turn_accepts_output_schema_v1() -> Result<()> { model: "mock-model".to_string(), effort: Some(ReasoningEffort::Medium), summary: ReasoningSummary::Auto, + service_tier: None, output_schema: Some(output_schema.clone()), }) .await?; @@ -184,6 +185,7 @@ async fn send_user_turn_rejects_oversized_input_v1() -> Result<()> { model: "mock-model".to_string(), effort: Some(ReasoningEffort::Low), summary: ReasoningSummary::Auto, + service_tier: None, output_schema: None, }) .await?; @@ -273,6 +275,7 @@ async fn send_user_turn_output_schema_is_per_turn_v1() -> Result<()> { model: "mock-model".to_string(), effort: Some(ReasoningEffort::Medium), summary: ReasoningSummary::Auto, + service_tier: None, output_schema: Some(output_schema.clone()), }) .await?; @@ -321,6 +324,7 @@ async fn send_user_turn_output_schema_is_per_turn_v1() -> Result<()> { model: "mock-model".to_string(), effort: Some(ReasoningEffort::Medium), summary: ReasoningSummary::Auto, + service_tier: None, output_schema: None, }) .await?; diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index 0d05d64465..e64287cb22 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -30,7 +30,7 @@ async fn app_server_default_analytics_disabled_without_flag() -> Result<()> { let provider = codex_core::otel_init::build_provider( &config, SERVICE_VERSION, - Some("codex_app_server"), + Some("codex-app-server"), false, ) .map_err(|err| anyhow::anyhow!(err.to_string()))?; @@ -55,7 +55,7 @@ async fn app_server_default_analytics_enabled_with_flag() -> Result<()> { let provider = codex_core::otel_init::build_provider( &config, SERVICE_VERSION, - Some("codex_app_server"), + Some("codex-app-server"), true, ) .map_err(|err| anyhow::anyhow!(err.to_string()))?; diff --git a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs index 0bbbc70b55..9799945383 100644 --- a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs +++ b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs @@ -174,6 +174,7 @@ pub(super) async fn send_request( id: RequestId::Integer(id), method: method.to_string(), params, + trace: None, }); send_jsonrpc(stream, message).await } diff --git a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs index f2664848ca..139e8b378a 100644 --- a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs +++ b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs @@ -5,6 +5,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::LoginApiKeyParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ThreadRealtimeAppendAudioParams; use codex_app_server_protocol::ThreadRealtimeAppendAudioResponse; @@ -42,20 +43,17 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { let responses_server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let realtime_server = start_websocket_server(vec![vec![ - vec![json!({ - "type": "session.created", - "session": { "id": "sess_backend" } - })], vec![json!({ "type": "session.updated", - "session": { "backend_prompt": "backend prompt" } + "session": { "id": "sess_backend", "instructions": "backend prompt" } })], + vec![], vec![ json!({ - "type": "response.output_audio.delta", + "type": "conversation.output_audio.delta", "delta": "AQID", "sample_rate": 24_000, - "num_channels": 1, + "channels": 1, "samples_per_channel": 512 }), json!({ @@ -84,6 +82,7 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { let mut mcp = McpProcess::new(codex_home.path()).await?; mcp.initialize().await?; + login_with_api_key(&mut mcp, "sk-test-key").await?; let thread_start_request_id = mcp .send_thread_start_request(ThreadStartParams::default()) @@ -182,7 +181,7 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { assert_eq!(connection.len(), 3); assert_eq!( connection[0].body_json()["type"].as_str(), - Some("session.create") + Some("session.update") ); let mut request_types = [ connection[1].body_json()["type"] @@ -199,7 +198,7 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { request_types, [ "conversation.item.create".to_string(), - "response.input_audio.delta".to_string(), + "input_audio_buffer.append".to_string(), ] ); @@ -214,8 +213,8 @@ async fn realtime_conversation_stop_emits_closed_notification() -> Result<()> { let responses_server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let realtime_server = start_websocket_server(vec![vec![ vec![json!({ - "type": "session.created", - "session": { "id": "sess_backend" } + "type": "session.updated", + "session": { "id": "sess_backend", "instructions": "backend prompt" } })], vec![], ]]) @@ -231,6 +230,7 @@ async fn realtime_conversation_stop_emits_closed_notification() -> Result<()> { let mut mcp = McpProcess::new(codex_home.path()).await?; mcp.initialize().await?; + login_with_api_key(&mut mcp, "sk-test-key").await?; let thread_start_request_id = mcp .send_thread_start_request(ThreadStartParams::default()) @@ -349,6 +349,22 @@ async fn read_notification(mcp: &mut McpProcess, method: &s Ok(serde_json::from_value(params)?) } +async fn login_with_api_key(mcp: &mut McpProcess, api_key: &str) -> Result<()> { + let request_id = mcp + .send_login_api_key_request(LoginApiKeyParams { + api_key: api_key.to_string(), + }) + .await?; + + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + Ok(()) +} + fn create_config_toml( codex_home: &Path, responses_server_uri: &str, diff --git a/codex-rs/app-server/tests/suite/v2/review.rs b/codex-rs/app-server/tests/suite/v2/review.rs index 262b13969e..6febbaf52a 100644 --- a/codex-rs/app-server/tests/suite/v2/review.rs +++ b/codex-rs/app-server/tests/suite/v2/review.rs @@ -8,6 +8,7 @@ use app_test_support::to_response; use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::ItemStartedNotification; use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -19,9 +20,12 @@ use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::ThreadStartedNotification; +use codex_app_server_protocol::ThreadStatusChangedNotification; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput as V2UserInput; +use pretty_assertions::assert_eq; use serde_json::json; use tempfile::TempDir; use tokio::time::timeout; @@ -301,6 +305,31 @@ async fn review_start_with_detached_delivery_returns_new_thread_id() -> Result<( "detached review should run on a different thread" ); + let deadline = tokio::time::Instant::now() + DEFAULT_READ_TIMEOUT; + let notification = loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + let message = timeout(remaining, mcp.read_next_message()).await??; + let JSONRPCMessage::Notification(notification) = message else { + continue; + }; + if notification.method == "thread/status/changed" { + let status_changed: ThreadStatusChangedNotification = + serde_json::from_value(notification.params.expect("params must be present"))?; + if status_changed.thread_id == review_thread_id { + anyhow::bail!( + "detached review threads should be introduced without a preceding thread/status/changed" + ); + } + continue; + } + if notification.method == "thread/started" { + break notification; + } + }; + let started: ThreadStartedNotification = + serde_json::from_value(notification.params.expect("params must be present"))?; + assert_eq!(started.thread.id, review_thread_id); + Ok(()) } @@ -389,6 +418,11 @@ async fn start_default_thread(mcp: &mut McpProcess) -> Result { ) .await??; let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/started"), + ) + .await??; Ok(thread.id) } diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index a6f9942171..1f19ae8b97 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -4,7 +4,7 @@ use app_test_support::create_fake_rollout; use app_test_support::create_mock_responses_server_repeating_assistant; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCError; -use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SessionSource; @@ -15,6 +15,7 @@ use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStartedNotification; use codex_app_server_protocol::ThreadStatus; +use codex_app_server_protocol::ThreadStatusChangedNotification; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput; use pretty_assertions::assert_eq; @@ -124,11 +125,27 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { } // A corresponding thread/started notification should arrive. - let notif: JSONRPCNotification = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("thread/started"), - ) - .await??; + let deadline = tokio::time::Instant::now() + DEFAULT_READ_TIMEOUT; + let notif = loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + let message = timeout(remaining, mcp.read_next_message()).await??; + let JSONRPCMessage::Notification(notif) = message else { + continue; + }; + if notif.method == "thread/status/changed" { + let status_changed: ThreadStatusChangedNotification = + serde_json::from_value(notif.params.expect("params must be present"))?; + if status_changed.thread_id == thread.id { + anyhow::bail!( + "thread/fork should introduce the thread without a preceding thread/status/changed" + ); + } + continue; + } + if notif.method == "thread/started" { + break notif; + } + }; let started_params = notif.params.clone().expect("params must be present"); let started_thread_json = started_params .get("thread") diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index 196f128c46..dcc411538e 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -10,6 +10,7 @@ use codex_app_server_protocol::SessionSource; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadListResponse; +use codex_app_server_protocol::ThreadNameUpdatedNotification; use codex_app_server_protocol::ThreadReadParams; use codex_app_server_protocol::ThreadReadResponse; use codex_app_server_protocol::ThreadResumeParams; @@ -220,25 +221,6 @@ async fn thread_name_set_is_reflected_in_read_list_and_resume() -> Result<()> { let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - // `thread/name/set` operates on loaded threads (via ThreadManager). A rollout existing on disk - // is not enough; we must `thread/resume` first to load it into the running server. - let pre_resume_id = mcp - .send_thread_resume_request(ThreadResumeParams { - thread_id: conversation_id.clone(), - ..Default::default() - }) - .await?; - let pre_resume_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(pre_resume_id)), - ) - .await??; - let ThreadResumeResponse { - thread: pre_resumed, - .. - } = to_response::(pre_resume_resp)?; - assert_eq!(pre_resumed.id, conversation_id); - // Set a user-facing thread title. let new_name = "My renamed thread"; let set_id = mcp @@ -253,6 +235,15 @@ async fn thread_name_set_is_reflected_in_read_list_and_resume() -> Result<()> { ) .await??; let _: ThreadSetNameResponse = to_response::(set_resp)?; + let notification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/name/updated"), + ) + .await??; + let notification: ThreadNameUpdatedNotification = + serde_json::from_value(notification.params.expect("thread/name/updated params"))?; + assert_eq!(notification.thread_id, conversation_id); + assert_eq!(notification.thread_name.as_deref(), Some(new_name)); // Read should now surface `thread.name`, and the wire payload must include `name`. let read_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 7ae56761c1..9628a78164 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -1,4 +1,3 @@ -use anyhow::Context; use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; @@ -17,7 +16,6 @@ use codex_app_server_protocol::FileChangeApprovalDecision; use codex_app_server_protocol::FileChangeRequestApprovalResponse; use codex_app_server_protocol::ItemStartedNotification; use codex_app_server_protocol::JSONRPCError; -use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::PatchApplyStatus; use codex_app_server_protocol::PatchChangeKind; @@ -30,7 +28,6 @@ use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus; -use codex_app_server_protocol::ThreadStatusChangedNotification; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStatus; @@ -293,7 +290,7 @@ async fn thread_resume_keeps_in_flight_turn_streaming() -> Result<()> { .await??; timeout( DEFAULT_READ_TIMEOUT, - wait_for_thread_status_active(&mut primary, &thread.id), + primary.read_stream_until_notification_message("turn/started"), ) .await??; @@ -400,7 +397,7 @@ async fn thread_resume_rejects_history_when_thread_is_running() -> Result<()> { to_response::(running_turn_resp)?; timeout( DEFAULT_READ_TIMEOUT, - wait_for_thread_status_active(&mut primary, &thread_id), + primary.read_stream_until_notification_message("turn/started"), ) .await??; @@ -516,7 +513,7 @@ async fn thread_resume_rejects_mismatched_path_when_thread_is_running() -> Resul to_response::(running_turn_resp)?; timeout( DEFAULT_READ_TIMEOUT, - wait_for_thread_status_active(&mut primary, &thread_id), + primary.read_stream_until_notification_message("turn/started"), ) .await??; @@ -619,7 +616,7 @@ async fn thread_resume_rejoins_running_thread_even_with_override_mismatch() -> R .await??; timeout( DEFAULT_READ_TIMEOUT, - wait_for_thread_status_active(&mut primary, &thread.id), + primary.read_stream_until_notification_message("turn/started"), ) .await??; @@ -1419,30 +1416,6 @@ required = true ) } -async fn wait_for_thread_status_active( - mcp: &mut McpProcess, - thread_id: &str, -) -> Result { - loop { - let status_changed_notif: JSONRPCNotification = mcp - .read_stream_until_notification_message("thread/status/changed") - .await?; - let status_changed_params = status_changed_notif - .params - .context("thread/status/changed params must be present")?; - let status_changed: ThreadStatusChangedNotification = - serde_json::from_value(status_changed_params)?; - if status_changed.thread_id == thread_id - && status_changed.status - == (ThreadStatus::Active { - active_flags: Vec::new(), - }) - { - return Ok(status_changed); - } - } -} - #[allow(dead_code)] fn set_rollout_mtime(path: &Path, updated_at_rfc3339: &str) -> Result<()> { let parsed = chrono::DateTime::parse_from_rfc3339(updated_at_rfc3339)?.with_timezone(&Utc); diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 7871f58351..57eddd195a 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -3,16 +3,18 @@ use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_repeating_assistant; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCError; -use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStartedNotification; use codex_app_server_protocol::ThreadStatus; +use codex_app_server_protocol::ThreadStatusChangedNotification; use codex_core::config::set_project_trust_level; use codex_protocol::config_types::TrustLevel; use codex_protocol::openai_models::ReasoningEffort; +use pretty_assertions::assert_eq; use serde_json::Value; use std::path::Path; use tempfile::TempDir; @@ -92,11 +94,27 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> { assert_eq!(thread.name, None); // A corresponding thread/started notification should arrive. - let notif: JSONRPCNotification = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("thread/started"), - ) - .await??; + let deadline = tokio::time::Instant::now() + DEFAULT_READ_TIMEOUT; + let notif = loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + let message = timeout(remaining, mcp.read_next_message()).await??; + let JSONRPCMessage::Notification(notif) = message else { + continue; + }; + if notif.method == "thread/status/changed" { + let status_changed: ThreadStatusChangedNotification = + serde_json::from_value(notif.params.expect("params must be present"))?; + if status_changed.thread_id == thread.id { + anyhow::bail!( + "thread/start should introduce the thread without a preceding thread/status/changed" + ); + } + continue; + } + if notif.method == "thread/started" { + break notif; + } + }; let started_params = notif.params.clone().expect("params must be present"); let started_thread_json = started_params .get("thread") diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 44063a3cad..0888d0f23e 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -434,6 +434,21 @@ async fn turn_start_emits_notifications_and_accepts_model_override() -> Result<( started.turn.status, codex_app_server_protocol::TurnStatus::InProgress ); + assert_eq!(started.turn.id, turn.id); + + let completed_notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notif + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed.thread_id, thread.id); + assert_eq!(completed.turn.id, turn.id); + assert_eq!(completed.turn.status, TurnStatus::Completed); // Send a second turn that exercises the overrides path: change the model. let turn_req2 = mcp @@ -457,25 +472,30 @@ async fn turn_start_emits_notifications_and_accepts_model_override() -> Result<( // Ensure the second turn has a different id than the first. assert_ne!(turn.id, turn2.id); - // Expect a second turn/started notification as well. - let _notif2: JSONRPCNotification = timeout( + let notif2: JSONRPCNotification = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_notification_message("turn/started"), ) .await??; + let started2: TurnStartedNotification = + serde_json::from_value(notif2.params.expect("params must be present"))?; + assert_eq!(started2.thread_id, thread.id); + assert_eq!(started2.turn.id, turn2.id); + assert_eq!(started2.turn.status, TurnStatus::InProgress); - let completed_notif: JSONRPCNotification = timeout( + let completed_notif2: JSONRPCNotification = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_notification_message("turn/completed"), ) .await??; - let completed: TurnCompletedNotification = serde_json::from_value( - completed_notif + let completed2: TurnCompletedNotification = serde_json::from_value( + completed_notif2 .params .expect("turn/completed params must be present"), )?; - assert_eq!(completed.thread_id, thread.id); - assert_eq!(completed.turn.status, TurnStatus::Completed); + assert_eq!(completed2.thread_id, thread.id); + assert_eq!(completed2.turn.id, turn2.id); + assert_eq!(completed2.turn.status, TurnStatus::Completed); Ok(()) } @@ -1365,6 +1385,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { model: Some("mock-model".to_string()), effort: Some(ReasoningEffort::Medium), summary: Some(ReasoningSummary::Auto), + service_tier: None, personality: None, output_schema: None, collaboration_mode: None, @@ -1396,6 +1417,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { model: Some("mock-model".to_string()), effort: Some(ReasoningEffort::Medium), summary: Some(ReasoningSummary::Auto), + service_tier: None, personality: None, output_schema: None, collaboration_mode: None, diff --git a/codex-rs/artifact-presentation/BUILD.bazel b/codex-rs/artifact-presentation/BUILD.bazel new file mode 100644 index 0000000000..d89e395b3f --- /dev/null +++ b/codex-rs/artifact-presentation/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "artifact-presentation", + crate_name = "codex_artifact_presentation", +) diff --git a/codex-rs/artifact-presentation/Cargo.toml b/codex-rs/artifact-presentation/Cargo.toml new file mode 100644 index 0000000000..898beb40f0 --- /dev/null +++ b/codex-rs/artifact-presentation/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "codex-artifact-presentation" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +name = "codex_artifact_presentation" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +base64 = { workspace = true } +image = { workspace = true, features = ["jpeg", "png"] } +ppt-rs = { workspace = true } +reqwest = { workspace = true, features = ["blocking"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +thiserror = { workspace = true } +uuid = { workspace = true, features = ["v4"] } +zip = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } +tiny_http = { workspace = true } diff --git a/codex-rs/artifact-presentation/src/lib.rs b/codex-rs/artifact-presentation/src/lib.rs new file mode 100644 index 0000000000..34bca50a91 --- /dev/null +++ b/codex-rs/artifact-presentation/src/lib.rs @@ -0,0 +1,6 @@ +mod presentation_artifact; + +#[cfg(test)] +mod tests; + +pub use presentation_artifact::*; diff --git a/codex-rs/artifact-presentation/src/presentation_artifact/api.rs b/codex-rs/artifact-presentation/src/presentation_artifact/api.rs new file mode 100644 index 0000000000..5680bf97fe --- /dev/null +++ b/codex-rs/artifact-presentation/src/presentation_artifact/api.rs @@ -0,0 +1,249 @@ +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use image::GenericImageView; +use image::ImageFormat; +use image::codecs::jpeg::JpegEncoder; +use image::imageops::FilterType; +use ppt_rs::Chart; +use ppt_rs::ChartSeries; +use ppt_rs::ChartType; +use ppt_rs::Hyperlink as PptHyperlink; +use ppt_rs::HyperlinkAction as PptHyperlinkAction; +use ppt_rs::Image; +use ppt_rs::Presentation; +use ppt_rs::Shape; +use ppt_rs::ShapeFill; +use ppt_rs::ShapeLine; +use ppt_rs::ShapeType; +use ppt_rs::SlideContent; +use ppt_rs::SlideLayout; +use ppt_rs::TableBuilder; +use ppt_rs::TableCell; +use ppt_rs::TableRow; +use ppt_rs::generator::ArrowSize; +use ppt_rs::generator::ArrowType; +use ppt_rs::generator::CellAlign; +use ppt_rs::generator::Connector; +use ppt_rs::generator::ConnectorLine; +use ppt_rs::generator::ConnectorType; +use ppt_rs::generator::LineDash; +use ppt_rs::generator::generate_image_content_type; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value; +use std::collections::BTreeSet; +use std::collections::HashMap; +use std::collections::HashSet; +use std::io::Cursor; +use std::io::Read; +use std::io::Seek; +use std::io::Write; +use std::path::Path; +use std::path::PathBuf; +use std::process::Command; +use thiserror::Error; +use uuid::Uuid; +use zip::ZipArchive; +use zip::ZipWriter; +use zip::write::SimpleFileOptions; + +const POINT_TO_EMU: u32 = 12_700; +const DEFAULT_SLIDE_WIDTH_POINTS: u32 = 720; +const DEFAULT_SLIDE_HEIGHT_POINTS: u32 = 540; +const DEFAULT_IMPORTED_TITLE_LEFT: u32 = 36; +const DEFAULT_IMPORTED_TITLE_TOP: u32 = 24; +const DEFAULT_IMPORTED_TITLE_WIDTH: u32 = 648; +const DEFAULT_IMPORTED_TITLE_HEIGHT: u32 = 48; +const DEFAULT_IMPORTED_CONTENT_LEFT: u32 = 48; +const DEFAULT_IMPORTED_CONTENT_TOP: u32 = 96; +const DEFAULT_IMPORTED_CONTENT_WIDTH: u32 = 624; +const DEFAULT_IMPORTED_CONTENT_HEIGHT: u32 = 324; + +#[derive(Debug, Error)] +pub enum PresentationArtifactError { + #[error("missing `artifact_id` for action `{action}`")] + MissingArtifactId { action: String }, + #[error("unknown artifact id `{artifact_id}` for action `{action}`")] + UnknownArtifactId { action: String, artifact_id: String }, + #[error("unknown action `{0}`")] + UnknownAction(String), + #[error("invalid args for action `{action}`: {message}")] + InvalidArgs { action: String, message: String }, + #[error("unsupported feature for action `{action}`: {message}")] + UnsupportedFeature { action: String, message: String }, + #[error("failed to import PPTX `{path}`: {message}")] + ImportFailed { path: PathBuf, message: String }, + #[error("failed to export PPTX `{path}`: {message}")] + ExportFailed { path: PathBuf, message: String }, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct PresentationArtifactRequest { + pub artifact_id: Option, + pub action: String, + #[serde(default)] + pub args: Value, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PresentationArtifactToolRequest { + pub artifact_id: Option, + pub actions: Vec, +} + +#[derive(Debug, Clone)] +pub struct PresentationArtifactExecutionRequest { + pub artifact_id: Option, + pub requests: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PresentationArtifactToolAction { + pub action: String, + #[serde(default)] + pub args: Value, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PathAccessKind { + Read, + Write, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PathAccessRequirement { + pub action: String, + pub kind: PathAccessKind, + pub path: PathBuf, +} + +impl PresentationArtifactRequest { + pub fn is_mutating(&self) -> bool { + !is_read_only_action(&self.action) + } + + pub fn required_path_accesses( + &self, + cwd: &Path, + ) -> Result, PresentationArtifactError> { + let access = match self.action.as_str() { + "import_pptx" => { + let args: ImportPptxArgs = parse_args(&self.action, &self.args)?; + vec![PathAccessRequirement { + action: self.action.clone(), + kind: PathAccessKind::Read, + path: resolve_path(cwd, &args.path), + }] + } + "export_pptx" => { + let args: ExportPptxArgs = parse_args(&self.action, &self.args)?; + vec![PathAccessRequirement { + action: self.action.clone(), + kind: PathAccessKind::Write, + path: resolve_path(cwd, &args.path), + }] + } + "export_preview" => { + let args: ExportPreviewArgs = parse_args(&self.action, &self.args)?; + vec![PathAccessRequirement { + action: self.action.clone(), + kind: PathAccessKind::Write, + path: resolve_path(cwd, &args.path), + }] + } + "add_image" => { + let args: AddImageArgs = parse_args(&self.action, &self.args)?; + match args.image_source()? { + ImageInputSource::Path(path) => vec![PathAccessRequirement { + action: self.action.clone(), + kind: PathAccessKind::Read, + path: resolve_path(cwd, &path), + }], + ImageInputSource::DataUrl(_) + | ImageInputSource::Blob(_) + | ImageInputSource::Uri(_) + | ImageInputSource::Placeholder => Vec::new(), + } + } + "replace_image" => { + let args: ReplaceImageArgs = parse_args(&self.action, &self.args)?; + match ( + &args.path, + &args.data_url, + &args.blob, + &args.uri, + &args.prompt, + ) { + (Some(path), None, None, None, None) => vec![PathAccessRequirement { + action: self.action.clone(), + kind: PathAccessKind::Read, + path: resolve_path(cwd, path), + }], + (None, Some(_), None, None, None) + | (None, None, Some(_), None, None) + | (None, None, None, Some(_), None) + | (None, None, None, None, Some(_)) => Vec::new(), + _ => { + return Err(PresentationArtifactError::InvalidArgs { + action: self.action.clone(), + message: + "provide exactly one of `path`, `data_url`, `blob`, or `uri`, or provide `prompt` for a placeholder image" + .to_string(), + }); + } + } + } + _ => Vec::new(), + }; + Ok(access) + } +} + +impl PresentationArtifactToolRequest { + pub fn is_mutating(&self) -> Result { + Ok(self.actions.iter().any(|request| !is_read_only_action(&request.action))) + } + + pub fn into_execution_request( + self, + ) -> Result { + if self.actions.is_empty() { + return Err(PresentationArtifactError::InvalidArgs { + action: "presentation_artifact".to_string(), + message: "`actions` must contain at least one item".to_string(), + }); + } + Ok(PresentationArtifactExecutionRequest { + artifact_id: self.artifact_id, + requests: self + .actions + .into_iter() + .map(|request| PresentationArtifactRequest { + artifact_id: None, + action: request.action, + args: request.args, + }) + .collect(), + }) + } + + pub fn required_path_accesses( + &self, + cwd: &Path, + ) -> Result, PresentationArtifactError> { + let mut accesses = Vec::new(); + for request in &self.actions { + accesses.extend( + PresentationArtifactRequest { + artifact_id: None, + action: request.action.clone(), + args: request.args.clone(), + } + .required_path_accesses(cwd)?, + ); + } + Ok(accesses) + } +} diff --git a/codex-rs/artifact-presentation/src/presentation_artifact/args.rs b/codex-rs/artifact-presentation/src/presentation_artifact/args.rs new file mode 100644 index 0000000000..a139dcb055 --- /dev/null +++ b/codex-rs/artifact-presentation/src/presentation_artifact/args.rs @@ -0,0 +1,729 @@ +#[derive(Debug, Deserialize)] +struct CreateArgs { + name: Option, + slide_size: Option, + theme: Option, +} + +#[derive(Debug, Deserialize)] +struct ImportPptxArgs { + path: PathBuf, +} + +#[derive(Debug, Deserialize)] +struct ExportPptxArgs { + path: PathBuf, +} + +#[derive(Debug, Deserialize)] +struct ExportPreviewArgs { + path: PathBuf, + slide_index: Option, + format: Option, + scale: Option, + quality: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct AddSlideArgs { + layout: Option, + notes: Option, + background_fill: Option, +} + +#[derive(Debug, Deserialize)] +struct CreateLayoutArgs { + name: String, + kind: Option, + parent_layout_id: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PreviewOutputFormat { + Png, + Jpeg, + Svg, +} + +impl PreviewOutputFormat { + fn extension(self) -> &'static str { + match self { + Self::Png => "png", + Self::Jpeg => "jpg", + Self::Svg => "svg", + } + } +} + +#[derive(Debug, Deserialize)] +struct AddLayoutPlaceholderArgs { + layout_id: String, + name: String, + placeholder_type: String, + index: Option, + text: Option, + geometry: Option, + position: Option, +} + +#[derive(Debug, Deserialize)] +struct LayoutIdArgs { + layout_id: String, +} + +#[derive(Debug, Deserialize)] +struct SetSlideLayoutArgs { + slide_index: u32, + layout_id: String, +} + +#[derive(Debug, Deserialize)] +struct UpdatePlaceholderTextArgs { + slide_index: u32, + name: String, + text: String, +} + +#[derive(Debug, Deserialize)] +struct NotesArgs { + slide_index: u32, + text: Option, +} + +#[derive(Debug, Deserialize)] +struct NotesVisibilityArgs { + slide_index: u32, + visible: bool, +} + +#[derive(Debug, Deserialize)] +struct ThemeArgs { + color_scheme: HashMap, + major_font: Option, + minor_font: Option, +} + +#[derive(Debug, Deserialize)] +struct StyleNameArgs { + name: String, +} + +#[derive(Debug, Deserialize)] +struct AddStyleArgs { + name: String, + #[serde(flatten)] + styling: TextStylingArgs, +} + +#[derive(Debug, Deserialize)] +struct InspectArgs { + kind: Option, + include: Option, + exclude: Option, + search: Option, + target_id: Option, + target: Option, + max_chars: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct InspectTargetArgs { + id: String, + before_lines: Option, + after_lines: Option, +} + +#[derive(Debug, Deserialize)] +struct ResolveArgs { + id: String, +} + +#[derive(Debug, Clone, Deserialize)] +struct PatchOperationInput { + artifact_id: Option, + action: String, + #[serde(default)] + args: Value, +} + +#[derive(Debug, Deserialize)] +struct RecordPatchArgs { + operations: Vec, +} + +#[derive(Debug, Deserialize)] +struct ApplyPatchArgs { + operations: Option>, + patch: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PresentationPatch { + version: u32, + artifact_id: String, + operations: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PatchOperation { + action: String, + #[serde(default)] + args: Value, +} + +#[derive(Debug, Default, Deserialize)] +struct InsertSlideArgs { + index: Option, + after_slide_index: Option, + layout: Option, + notes: Option, + background_fill: Option, +} + +#[derive(Debug, Deserialize)] +struct SlideIndexArgs { + slide_index: u32, +} + +#[derive(Debug, Deserialize)] +struct MoveSlideArgs { + from_index: u32, + to_index: u32, +} + +#[derive(Debug, Deserialize)] +struct SetActiveSlideArgs { + slide_index: u32, +} + +#[derive(Debug, Deserialize)] +struct SetSlideBackgroundArgs { + slide_index: u32, + fill: String, +} + +#[derive(Debug, Clone, Deserialize)] +struct PositionArgs { + left: u32, + top: u32, + width: u32, + height: u32, + rotation: Option, + flip_horizontal: Option, + flip_vertical: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +struct PartialPositionArgs { + left: Option, + top: Option, + width: Option, + height: Option, + rotation: Option, + flip_horizontal: Option, + flip_vertical: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +struct TextStylingArgs { + style: Option, + font_size: Option, + font_family: Option, + color: Option, + fill: Option, + alignment: Option, + bold: Option, + italic: Option, + underline: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +struct TextLayoutArgs { + insets: Option, + wrap: Option, + auto_fit: Option, + vertical_alignment: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct TextInsetsArgs { + left: u32, + right: u32, + top: u32, + bottom: u32, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +enum RichTextInput { + Plain(String), + Paragraphs(Vec), +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +enum RichParagraphInput { + Plain(String), + Runs(Vec), +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +enum RichRunInput { + Plain(String), + Styled(RichRunObjectInput), +} + +#[derive(Debug, Clone, Deserialize)] +struct RichRunObjectInput { + run: String, + #[serde(default)] + text_style: TextStylingArgs, + link: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct RichTextLinkInput { + uri: Option, + is_external: Option, +} + +#[derive(Debug, Deserialize)] +struct AddTextShapeArgs { + slide_index: u32, + text: String, + position: PositionArgs, + #[serde(flatten)] + styling: TextStylingArgs, + #[serde(default)] + text_layout: TextLayoutArgs, +} + +#[derive(Debug, Clone, Default, Deserialize)] +struct StrokeArgs { + color: String, + width: u32, + style: Option, +} + +#[derive(Debug, Deserialize)] +struct AddShapeArgs { + slide_index: u32, + geometry: String, + position: PositionArgs, + fill: Option, + stroke: Option, + text: Option, + rotation: Option, + flip_horizontal: Option, + flip_vertical: Option, + #[serde(default)] + text_style: TextStylingArgs, + #[serde(default)] + text_layout: TextLayoutArgs, +} + +#[derive(Debug, Clone, Default, Deserialize)] +struct ConnectorLineArgs { + color: Option, + width: Option, + style: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PointArgs { + left: u32, + top: u32, +} + +#[derive(Debug, Deserialize)] +struct AddConnectorArgs { + slide_index: u32, + connector_type: String, + start: PointArgs, + end: PointArgs, + line: Option, + start_arrow: Option, + end_arrow: Option, + arrow_size: Option, + label: Option, +} + +#[derive(Debug, Deserialize)] +struct AddImageArgs { + slide_index: u32, + path: Option, + data_url: Option, + blob: Option, + uri: Option, + position: PositionArgs, + fit: Option, + crop: Option, + rotation: Option, + flip_horizontal: Option, + flip_vertical: Option, + lock_aspect_ratio: Option, + alt: Option, + prompt: Option, +} + +impl AddImageArgs { + fn image_source(&self) -> Result { + match (&self.path, &self.data_url, &self.blob, &self.uri) { + (Some(path), None, None, None) => Ok(ImageInputSource::Path(path.clone())), + (None, Some(data_url), None, None) => Ok(ImageInputSource::DataUrl(data_url.clone())), + (None, None, Some(blob), None) => Ok(ImageInputSource::Blob(blob.clone())), + (None, None, None, Some(uri)) => Ok(ImageInputSource::Uri(uri.clone())), + (None, None, None, None) if self.prompt.is_some() => Ok(ImageInputSource::Placeholder), + _ => Err(PresentationArtifactError::InvalidArgs { + action: "add_image".to_string(), + message: + "provide exactly one of `path`, `data_url`, `blob`, or `uri`, or provide `prompt` for a placeholder image" + .to_string(), + }), + } + } +} + +enum ImageInputSource { + Path(PathBuf), + DataUrl(String), + Blob(String), + Uri(String), + Placeholder, +} + +#[derive(Debug, Clone, Deserialize)] +struct ImageCropArgs { + left: f64, + top: f64, + right: f64, + bottom: f64, +} + +#[derive(Debug, Deserialize)] +struct AddTableArgs { + slide_index: u32, + position: PositionArgs, + rows: Vec>, + column_widths: Option>, + row_heights: Option>, + style: Option, + style_options: Option, + borders: Option, + right_to_left: Option, +} + +#[derive(Debug, Deserialize)] +struct AddChartArgs { + slide_index: u32, + position: PositionArgs, + chart_type: String, + categories: Vec, + series: Vec, + title: Option, + style_index: Option, + has_legend: Option, + legend_position: Option, + #[serde(default)] + legend_text_style: TextStylingArgs, + x_axis_title: Option, + y_axis_title: Option, + data_labels: Option, + chart_fill: Option, + plot_area_fill: Option, +} + +#[derive(Debug, Deserialize)] +struct ChartSeriesArgs { + name: String, + values: Vec, + categories: Option>, + x_values: Option>, + fill: Option, + stroke: Option, + marker: Option, + data_label_overrides: Option>, +} + +#[derive(Debug, Clone, Deserialize)] +struct ChartMarkerArgs { + symbol: Option, + size: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct ChartDataLabelsArgs { + show_value: Option, + show_category_name: Option, + show_leader_lines: Option, + position: Option, + #[serde(default)] + text_style: TextStylingArgs, +} + +#[derive(Debug, Clone, Deserialize)] +struct ChartDataLabelOverrideArgs { + idx: u32, + text: Option, + position: Option, + #[serde(default)] + text_style: TextStylingArgs, + fill: Option, + stroke: Option, +} + +#[derive(Debug, Deserialize)] +struct UpdateTextArgs { + element_id: String, + text: String, + #[serde(default)] + styling: TextStylingArgs, + #[serde(default)] + text_layout: TextLayoutArgs, +} + +#[derive(Debug, Deserialize)] +struct SetRichTextArgs { + element_id: Option, + slide_index: Option, + row: Option, + column: Option, + notes: Option, + text: RichTextInput, + #[serde(default)] + styling: TextStylingArgs, + #[serde(default)] + text_layout: TextLayoutArgs, +} + +#[derive(Debug, Deserialize)] +struct FormatTextRangeArgs { + element_id: Option, + slide_index: Option, + row: Option, + column: Option, + notes: Option, + query: Option, + occurrence: Option, + start_cp: Option, + length: Option, + #[serde(default)] + styling: TextStylingArgs, + #[serde(default)] + text_layout: TextLayoutArgs, + link: Option, + spacing_before: Option, + spacing_after: Option, + line_spacing: Option, +} + +#[derive(Debug, Deserialize)] +struct ReplaceTextArgs { + element_id: String, + search: String, + replace: String, +} + +#[derive(Debug, Deserialize)] +struct InsertTextAfterArgs { + element_id: String, + after: String, + insert: String, +} + +#[derive(Debug, Deserialize)] +struct SetHyperlinkArgs { + element_id: String, + link_type: Option, + url: Option, + slide_index: Option, + address: Option, + subject: Option, + path: Option, + tooltip: Option, + highlight_click: Option, + clear: Option, +} + +#[derive(Debug, Deserialize)] +struct UpdateShapeStyleArgs { + element_id: String, + position: Option, + fill: Option, + stroke: Option, + rotation: Option, + flip_horizontal: Option, + flip_vertical: Option, + fit: Option, + crop: Option, + lock_aspect_ratio: Option, + z_order: Option, + #[serde(default)] + text_layout: TextLayoutArgs, +} + +#[derive(Debug, Deserialize)] +struct ElementIdArgs { + element_id: String, +} + +#[derive(Debug, Deserialize)] +struct ReplaceImageArgs { + element_id: String, + path: Option, + data_url: Option, + blob: Option, + uri: Option, + fit: Option, + crop: Option, + rotation: Option, + flip_horizontal: Option, + flip_vertical: Option, + lock_aspect_ratio: Option, + alt: Option, + prompt: Option, +} + +#[derive(Debug, Deserialize)] +struct UpdateTableCellArgs { + element_id: String, + row: u32, + column: u32, + value: Value, + #[serde(default)] + styling: TextStylingArgs, + background_fill: Option, + alignment: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct TableStyleOptionsArgs { + header_row: Option, + banded_rows: Option, + banded_columns: Option, + first_column: Option, + last_column: Option, + total_row: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct TableBorderArgs { + color: String, + width: u32, +} + +#[derive(Debug, Clone, Deserialize)] +struct TableBordersArgs { + outside: Option, + inside: Option, + top: Option, + bottom: Option, + left: Option, + right: Option, +} + +#[derive(Debug, Deserialize)] +struct UpdateTableStyleArgs { + element_id: String, + style: Option, + style_options: Option, + borders: Option, + right_to_left: Option, +} + +#[derive(Debug, Deserialize)] +struct StyleTableBlockArgs { + element_id: String, + row: u32, + column: u32, + row_count: u32, + column_count: u32, + #[serde(default)] + styling: TextStylingArgs, + background_fill: Option, + alignment: Option, + borders: Option, +} + +#[derive(Debug, Deserialize)] +struct MergeTableCellsArgs { + element_id: String, + start_row: u32, + end_row: u32, + start_column: u32, + end_column: u32, +} + +#[derive(Debug, Deserialize)] +struct UpdateChartArgs { + element_id: String, + title: Option, + categories: Option>, + style_index: Option, + has_legend: Option, + legend_position: Option, + #[serde(default)] + legend_text_style: TextStylingArgs, + x_axis_title: Option, + y_axis_title: Option, + data_labels: Option, + chart_fill: Option, + plot_area_fill: Option, +} + +#[derive(Debug, Deserialize)] +struct AddChartSeriesArgs { + element_id: String, + name: String, + values: Vec, + categories: Option>, + x_values: Option>, + fill: Option, + stroke: Option, + marker: Option, +} + +#[derive(Debug, Deserialize)] +struct SetCommentAuthorArgs { + display_name: String, + initials: String, + email: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct CommentPositionArgs { + x: u32, + y: u32, +} + +#[derive(Debug, Deserialize)] +struct AddCommentThreadArgs { + slide_index: Option, + element_id: Option, + query: Option, + occurrence: Option, + start_cp: Option, + length: Option, + text: String, + position: Option, +} + +#[derive(Debug, Deserialize)] +struct AddCommentReplyArgs { + thread_id: String, + text: String, +} + +#[derive(Debug, Deserialize)] +struct ToggleCommentReactionArgs { + thread_id: String, + message_id: Option, + emoji: String, +} + +#[derive(Debug, Deserialize)] +struct CommentThreadIdArgs { + thread_id: String, +} diff --git a/codex-rs/artifact-presentation/src/presentation_artifact/inspect.rs b/codex-rs/artifact-presentation/src/presentation_artifact/inspect.rs new file mode 100644 index 0000000000..608e0734ee --- /dev/null +++ b/codex-rs/artifact-presentation/src/presentation_artifact/inspect.rs @@ -0,0 +1,871 @@ +fn inspect_document(document: &PresentationDocument, args: &InspectArgs) -> String { + let include_kinds = args + .include + .as_deref() + .or(args.kind.as_deref()) + .unwrap_or( + "deck,slide,textbox,shape,connector,table,chart,image,notes,layoutList,textRange,comment", + ); + let included_kinds = include_kinds + .split(',') + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .collect::>(); + let excluded_kinds = args + .exclude + .as_deref() + .unwrap_or_default() + .split(',') + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .collect::>(); + let include = |name: &str| included_kinds.contains(name) && !excluded_kinds.contains(name); + let mut records: Vec<(Value, Option)> = Vec::new(); + if include("deck") { + records.push(( + serde_json::json!({ + "kind": "deck", + "id": format!("pr/{}", document.artifact_id), + "name": document.name, + "slides": document.slides.len(), + "styleIds": document + .named_text_styles() + .iter() + .map(|style| format!("st/{}", style.name)) + .collect::>(), + "activeSlideIndex": document.active_slide_index, + "activeSlideId": document.active_slide_index.and_then(|index| document.slides.get(index)).map(|slide| format!("sl/{}", slide.slide_id)), + "commentThreadIds": document + .comment_threads + .iter() + .map(|thread| format!("th/{}", thread.thread_id)) + .collect::>(), + }), + None, + )); + } + if include("styleList") { + for style in document.named_text_styles() { + records.push((named_text_style_to_json(&style, "st"), None)); + } + } + if include("layoutList") { + for layout in &document.layouts { + let placeholders = resolved_layout_placeholders(document, &layout.layout_id, "inspect") + .unwrap_or_default() + .into_iter() + .map(|placeholder| { + serde_json::json!({ + "name": placeholder.definition.name, + "type": placeholder.definition.placeholder_type, + "sourceLayoutId": placeholder.source_layout_id, + "textPreview": placeholder.definition.text, + }) + }) + .collect::>(); + records.push(( + serde_json::json!({ + "kind": "layout", + "id": format!("ly/{}", layout.layout_id), + "layoutId": layout.layout_id, + "name": layout.name, + "type": match layout.kind { LayoutKind::Layout => "layout", LayoutKind::Master => "master" }, + "parentLayoutId": layout.parent_layout_id, + "placeholders": placeholders, + }), + None, + )); + } + } + for (index, slide) in document.slides.iter().enumerate() { + let slide_id = format!("sl/{}", slide.slide_id); + if include("slide") { + records.push(( + serde_json::json!({ + "kind": "slide", + "id": slide_id, + "slide": index + 1, + "slideIndex": index, + "isActive": document.active_slide_index == Some(index), + "layoutId": slide.layout_id, + "elements": slide.elements.len(), + }), + Some(slide_id.clone()), + )); + } + if include("notes") && !slide.notes.text.is_empty() { + records.push(( + serde_json::json!({ + "kind": "notes", + "id": format!("nt/{}", slide.slide_id), + "slide": index + 1, + "visible": slide.notes.visible, + "text": slide.notes.text, + "textPreview": slide.notes.text.replace('\n', " | "), + "textChars": slide.notes.text.chars().count(), + "textLines": slide.notes.text.lines().count(), + "richText": rich_text_to_proto(&slide.notes.text, &slide.notes.rich_text), + }), + Some(slide_id.clone()), + )); + } + if include("textRange") { + records.extend( + slide + .notes + .rich_text + .ranges + .iter() + .map(|range| { + let mut record = text_range_to_proto(&slide.notes.text, range); + record["kind"] = Value::String("textRange".to_string()); + record["slide"] = Value::from(index + 1); + record["slideIndex"] = Value::from(index); + record["hostAnchor"] = Value::String(format!("nt/{}", slide.slide_id)); + record["hostKind"] = Value::String("notes".to_string()); + (record, Some(slide_id.clone())) + }), + ); + } + for element in &slide.elements { + let mut record = match element { + PresentationElement::Text(text) => { + if !include("textbox") { + continue; + } + serde_json::json!({ + "kind": "textbox", + "id": format!("sh/{}", text.element_id), + "slide": index + 1, + "text": text.text, + "textStyle": text_style_to_proto(&text.style), + "textPreview": text.text.replace('\n', " | "), + "textChars": text.text.chars().count(), + "textLines": text.text.lines().count(), + "richText": rich_text_to_proto(&text.text, &text.rich_text), + "bbox": [text.frame.left, text.frame.top, text.frame.width, text.frame.height], + "bboxUnit": "points", + }) + } + PresentationElement::Shape(shape) => { + if !(include("shape") || include("textbox") && shape.text.is_some()) { + continue; + } + let kind = if shape.text.is_some() && include("textbox") { + "textbox" + } else { + "shape" + }; + let mut record = serde_json::json!({ + "kind": kind, + "id": format!("sh/{}", shape.element_id), + "slide": index + 1, + "geometry": format!("{:?}", shape.geometry), + "text": shape.text, + "textStyle": text_style_to_proto(&shape.text_style), + "richText": shape + .text + .as_ref() + .zip(shape.rich_text.as_ref()) + .map(|(text, rich_text)| rich_text_to_proto(text, rich_text)) + .unwrap_or(Value::Null), + "rotation": shape.rotation_degrees, + "flipHorizontal": shape.flip_horizontal, + "flipVertical": shape.flip_vertical, + "bbox": [shape.frame.left, shape.frame.top, shape.frame.width, shape.frame.height], + "bboxUnit": "points", + }); + if let Some(text) = &shape.text { + record["textPreview"] = Value::String(text.replace('\n', " | ")); + record["textChars"] = Value::from(text.chars().count()); + record["textLines"] = Value::from(text.lines().count()); + } + record + } + PresentationElement::Connector(connector) => { + if !include("shape") && !include("connector") { + continue; + } + serde_json::json!({ + "kind": "connector", + "id": format!("cn/{}", connector.element_id), + "slide": index + 1, + "connectorType": format!("{:?}", connector.connector_type), + "start": [connector.start.left, connector.start.top], + "end": [connector.end.left, connector.end.top], + "lineStyle": format!("{:?}", connector.line_style), + "label": connector.label, + }) + } + PresentationElement::Table(table) => { + if !include("table") { + continue; + } + serde_json::json!({ + "kind": "table", + "id": format!("tb/{}", table.element_id), + "slide": index + 1, + "rows": table.rows.len(), + "cols": table.rows.iter().map(std::vec::Vec::len).max().unwrap_or(0), + "columnWidths": table.column_widths, + "rowHeights": table.row_heights, + "preview": table.rows.first().map(|row| row.iter().map(|cell| cell.text.clone()).collect::>().join(" | ")), + "style": table.style, + "styleOptions": table_style_options_to_proto(&table.style_options), + "borders": table.borders.as_ref().map(table_borders_to_proto), + "rightToLeft": table.right_to_left, + "cellTextStyles": table + .rows + .iter() + .map(|row| row.iter().map(|cell| text_style_to_proto(&cell.text_style)).collect::>()) + .collect::>(), + "rowsData": table + .rows + .iter() + .map(|row| row.iter().map(table_cell_to_proto).collect::>()) + .collect::>(), + "bbox": [table.frame.left, table.frame.top, table.frame.width, table.frame.height], + "bboxUnit": "points", + }) + } + PresentationElement::Chart(chart) => { + if !include("chart") { + continue; + } + serde_json::json!({ + "kind": "chart", + "id": format!("ch/{}", chart.element_id), + "slide": index + 1, + "chartType": format!("{:?}", chart.chart_type), + "title": chart.title, + "styleIndex": chart.style_index, + "hasLegend": chart.has_legend, + "legend": chart.legend.as_ref().map(chart_legend_to_proto), + "xAxis": chart.x_axis.as_ref().map(chart_axis_to_proto), + "yAxis": chart.y_axis.as_ref().map(chart_axis_to_proto), + "dataLabels": chart.data_labels.as_ref().map(chart_data_labels_to_proto), + "chartFill": chart.chart_fill, + "plotAreaFill": chart.plot_area_fill, + "series": chart + .series + .iter() + .map(|series| serde_json::json!({ + "name": series.name, + "values": series.values, + "categories": series.categories, + "xValues": series.x_values, + "fill": series.fill, + "stroke": series.stroke.as_ref().map(stroke_to_proto), + "marker": series.marker.as_ref().map(chart_marker_to_proto), + "dataLabelOverrides": series + .data_label_overrides + .iter() + .map(chart_data_label_override_to_proto) + .collect::>(), + })) + .collect::>(), + "bbox": [chart.frame.left, chart.frame.top, chart.frame.width, chart.frame.height], + "bboxUnit": "points", + }) + } + PresentationElement::Image(image) => { + if !include("image") { + continue; + } + serde_json::json!({ + "kind": "image", + "id": format!("im/{}", image.element_id), + "slide": index + 1, + "alt": image.alt_text, + "prompt": image.prompt, + "fit": format!("{:?}", image.fit_mode), + "rotation": image.rotation_degrees, + "flipHorizontal": image.flip_horizontal, + "flipVertical": image.flip_vertical, + "crop": image.crop.map(|(left, top, right, bottom)| serde_json::json!({ + "left": left, + "top": top, + "right": right, + "bottom": bottom, + })), + "isPlaceholder": image.is_placeholder, + "lockAspectRatio": image.lock_aspect_ratio, + "bbox": [image.frame.left, image.frame.top, image.frame.width, image.frame.height], + "bboxUnit": "points", + }) + } + }; + if let Some(placeholder) = match element { + PresentationElement::Text(text) => text.placeholder.as_ref(), + PresentationElement::Shape(shape) => shape.placeholder.as_ref(), + PresentationElement::Connector(_) + | PresentationElement::Table(_) + | PresentationElement::Chart(_) => None, + PresentationElement::Image(image) => image.placeholder.as_ref(), + } { + record["placeholder"] = Value::String(placeholder.placeholder_type.clone()); + record["placeholderName"] = Value::String(placeholder.name.clone()); + record["placeholderIndex"] = + placeholder.index.map(Value::from).unwrap_or(Value::Null); + } + if let PresentationElement::Shape(shape) = element + && let Some(stroke) = &shape.stroke + { + record["stroke"] = serde_json::json!({ + "color": stroke.color, + "width": stroke.width, + "style": stroke.style.as_api_str(), + }); + } + if let Some(hyperlink) = match element { + PresentationElement::Text(text) => text.hyperlink.as_ref(), + PresentationElement::Shape(shape) => shape.hyperlink.as_ref(), + PresentationElement::Connector(_) + | PresentationElement::Image(_) + | PresentationElement::Table(_) + | PresentationElement::Chart(_) => None, + } { + record["hyperlink"] = hyperlink.to_json(); + } + records.push((record, Some(slide_id.clone()))); + if include("textRange") { + match element { + PresentationElement::Text(text) => { + records.extend(text.rich_text.ranges.iter().map(|range| { + let mut record = text_range_to_proto(&text.text, range); + record["kind"] = Value::String("textRange".to_string()); + record["slide"] = Value::from(index + 1); + record["slideIndex"] = Value::from(index); + record["hostAnchor"] = Value::String(format!("sh/{}", text.element_id)); + record["hostKind"] = Value::String("textbox".to_string()); + (record, Some(slide_id.clone())) + })); + } + PresentationElement::Shape(shape) => { + if let Some((text, rich_text)) = shape.text.as_ref().zip(shape.rich_text.as_ref()) { + records.extend(rich_text.ranges.iter().map(|range| { + let mut record = text_range_to_proto(text, range); + record["kind"] = Value::String("textRange".to_string()); + record["slide"] = Value::from(index + 1); + record["slideIndex"] = Value::from(index); + record["hostAnchor"] = Value::String(format!("sh/{}", shape.element_id)); + record["hostKind"] = Value::String("textbox".to_string()); + (record, Some(slide_id.clone())) + })); + } + } + PresentationElement::Table(table) => { + for (row_index, row) in table.rows.iter().enumerate() { + for (column_index, cell) in row.iter().enumerate() { + records.extend(cell.rich_text.ranges.iter().map(|range| { + let mut record = text_range_to_proto(&cell.text, range); + record["kind"] = Value::String("textRange".to_string()); + record["slide"] = Value::from(index + 1); + record["slideIndex"] = Value::from(index); + record["hostAnchor"] = Value::String(format!( + "tb/{}#cell/{row_index}/{column_index}", + table.element_id + )); + record["hostKind"] = Value::String("tableCell".to_string()); + (record, Some(slide_id.clone())) + })); + } + } + } + PresentationElement::Connector(_) + | PresentationElement::Image(_) + | PresentationElement::Chart(_) => {} + } + } + } + } + if include("comment") { + records.extend(document.comment_threads.iter().map(|thread| { + let mut record = comment_thread_to_proto(thread); + record["id"] = Value::String(format!("th/{}", thread.thread_id)); + (record, None) + })); + } + + if let Some(target_id) = args.target_id.as_deref() { + records.retain(|(record, slide_id)| { + legacy_target_matches(target_id, record, slide_id.as_deref()) + }); + if records.is_empty() { + records.push(( + serde_json::json!({ + "kind": "notice", + "noticeType": "targetNotFound", + "target": { "id": target_id }, + "message": format!("No inspect records matched target `{target_id}`."), + }), + None, + )); + } + } + + if let Some(search) = args.search.as_deref() { + let search_lowercase = search.to_ascii_lowercase(); + records.retain(|(record, _)| { + record + .to_string() + .to_ascii_lowercase() + .contains(&search_lowercase) + }); + if records.is_empty() { + records.push(( + serde_json::json!({ + "kind": "notice", + "noticeType": "noMatches", + "search": search, + "message": format!("No inspect records matched search `{search}`."), + }), + None, + )); + } + } + + if let Some(target) = args.target.as_ref() { + if let Some(target_index) = records.iter().position(|(record, _)| { + record.get("id").and_then(Value::as_str) == Some(target.id.as_str()) + }) { + let start = target_index.saturating_sub(target.before_lines.unwrap_or(0)); + let end = (target_index + target.after_lines.unwrap_or(0) + 1).min(records.len()); + records = records.into_iter().skip(start).take(end - start).collect(); + } else { + records = vec![( + serde_json::json!({ + "kind": "notice", + "noticeType": "targetNotFound", + "target": { + "id": target.id, + "beforeLines": target.before_lines, + "afterLines": target.after_lines, + }, + "message": format!("No inspect records matched target `{}`.", target.id), + }), + None, + )]; + } + } + + let mut lines = Vec::new(); + let mut omitted_lines = 0usize; + let mut omitted_chars = 0usize; + for line in records.into_iter().map(|(record, _)| record.to_string()) { + let separator_len = usize::from(!lines.is_empty()); + if let Some(max_chars) = args.max_chars + && lines.iter().map(String::len).sum::() + separator_len + line.len() > max_chars + { + omitted_lines += 1; + omitted_chars += line.len(); + continue; + } + lines.push(line); + } + if omitted_lines > 0 { + lines.push( + serde_json::json!({ + "kind": "notice", + "noticeType": "truncation", + "maxChars": args.max_chars, + "omittedLines": omitted_lines, + "omittedChars": omitted_chars, + "message": format!( + "Truncated inspect output by omitting {omitted_lines} lines. Increase maxChars or narrow the filter." + ), + }) + .to_string(), + ); + } + lines.join("\n") +} + +fn legacy_target_matches(target_id: &str, record: &Value, slide_id: Option<&str>) -> bool { + record.get("id").and_then(Value::as_str) == Some(target_id) || slide_id == Some(target_id) +} + +fn add_text_metadata(record: &mut Value, text: &str) { + record["textPreview"] = Value::String(text.replace('\n', " | ")); + record["textChars"] = Value::from(text.chars().count()); + record["textLines"] = Value::from(text.lines().count()); +} + +fn normalize_element_lookup_id(element_id: &str) -> &str { + element_id + .split_once('/') + .map(|(_, normalized)| normalized) + .unwrap_or(element_id) +} + +fn resolve_anchor( + document: &PresentationDocument, + id: &str, + action: &str, +) -> Result { + if id == format!("pr/{}", document.artifact_id) { + return Ok(serde_json::json!({ + "kind": "deck", + "id": id, + "artifactId": document.artifact_id, + "name": document.name, + "slideCount": document.slides.len(), + "styleIds": document + .named_text_styles() + .iter() + .map(|style| format!("st/{}", style.name)) + .collect::>(), + "activeSlideIndex": document.active_slide_index, + "activeSlideId": document.active_slide_index.and_then(|index| document.slides.get(index)).map(|slide| format!("sl/{}", slide.slide_id)), + })); + } + if let Some(style_name) = id.strip_prefix("st/") { + let named_style = document + .named_text_styles() + .into_iter() + .find(|style| style.name == style_name) + .ok_or_else(|| PresentationArtifactError::UnsupportedFeature { + action: action.to_string(), + message: format!("unknown style id `{id}`"), + })?; + return Ok(named_text_style_to_json(&named_style, "st")); + } + + for (slide_index, slide) in document.slides.iter().enumerate() { + let slide_id = format!("sl/{}", slide.slide_id); + if id == slide_id { + return Ok(serde_json::json!({ + "kind": "slide", + "id": slide_id, + "slide": slide_index + 1, + "slideIndex": slide_index, + "isActive": document.active_slide_index == Some(slide_index), + "layoutId": slide.layout_id, + "notesId": (!slide.notes.text.is_empty()).then(|| format!("nt/{}", slide.slide_id)), + "elementIds": slide.elements.iter().map(|element| { + let prefix = match element { + PresentationElement::Text(_) | PresentationElement::Shape(_) => "sh", + PresentationElement::Connector(_) => "cn", + PresentationElement::Image(_) => "im", + PresentationElement::Table(_) => "tb", + PresentationElement::Chart(_) => "ch", + }; + format!("{prefix}/{}", element.element_id()) + }).collect::>(), + })); + } + let notes_id = format!("nt/{}", slide.slide_id); + if id == notes_id { + let mut record = serde_json::json!({ + "kind": "notes", + "id": notes_id, + "slide": slide_index + 1, + "slideIndex": slide_index, + "visible": slide.notes.visible, + "text": slide.notes.text, + }); + add_text_metadata(&mut record, &slide.notes.text); + record["richText"] = rich_text_to_proto(&slide.notes.text, &slide.notes.rich_text); + return Ok(record); + } + if let Some(range_id) = id.strip_prefix("tr/") + && let Some(record) = slide + .notes + .rich_text + .ranges + .iter() + .find(|range| range.range_id == range_id) + .map(|range| { + let mut record = text_range_to_proto(&slide.notes.text, range); + record["kind"] = Value::String("textRange".to_string()); + record["id"] = Value::String(id.to_string()); + record["slide"] = Value::from(slide_index + 1); + record["slideIndex"] = Value::from(slide_index); + record["hostAnchor"] = Value::String(notes_id.clone()); + record["hostKind"] = Value::String("notes".to_string()); + record + }) + { + return Ok(record); + } + for element in &slide.elements { + let mut record = match element { + PresentationElement::Text(text) => { + let mut record = serde_json::json!({ + "kind": "textbox", + "id": format!("sh/{}", text.element_id), + "elementId": text.element_id, + "slide": slide_index + 1, + "slideIndex": slide_index, + "text": text.text, + "textStyle": text_style_to_proto(&text.style), + "richText": rich_text_to_proto(&text.text, &text.rich_text), + "bbox": [text.frame.left, text.frame.top, text.frame.width, text.frame.height], + "bboxUnit": "points", + }); + add_text_metadata(&mut record, &text.text); + record + } + PresentationElement::Shape(shape) => { + let mut record = serde_json::json!({ + "kind": if shape.text.is_some() { "textbox" } else { "shape" }, + "id": format!("sh/{}", shape.element_id), + "elementId": shape.element_id, + "slide": slide_index + 1, + "slideIndex": slide_index, + "geometry": format!("{:?}", shape.geometry), + "text": shape.text, + "textStyle": text_style_to_proto(&shape.text_style), + "richText": shape + .text + .as_ref() + .zip(shape.rich_text.as_ref()) + .map(|(text, rich_text)| rich_text_to_proto(text, rich_text)) + .unwrap_or(Value::Null), + "rotation": shape.rotation_degrees, + "flipHorizontal": shape.flip_horizontal, + "flipVertical": shape.flip_vertical, + "bbox": [shape.frame.left, shape.frame.top, shape.frame.width, shape.frame.height], + "bboxUnit": "points", + }); + if let Some(text) = &shape.text { + add_text_metadata(&mut record, text); + } + record + } + PresentationElement::Connector(connector) => serde_json::json!({ + "kind": "connector", + "id": format!("cn/{}", connector.element_id), + "elementId": connector.element_id, + "slide": slide_index + 1, + "slideIndex": slide_index, + "connectorType": format!("{:?}", connector.connector_type), + "start": [connector.start.left, connector.start.top], + "end": [connector.end.left, connector.end.top], + "lineStyle": format!("{:?}", connector.line_style), + "label": connector.label, + }), + PresentationElement::Image(image) => serde_json::json!({ + "kind": "image", + "id": format!("im/{}", image.element_id), + "elementId": image.element_id, + "slide": slide_index + 1, + "slideIndex": slide_index, + "alt": image.alt_text, + "prompt": image.prompt, + "fit": format!("{:?}", image.fit_mode), + "rotation": image.rotation_degrees, + "flipHorizontal": image.flip_horizontal, + "flipVertical": image.flip_vertical, + "crop": image.crop.map(|(left, top, right, bottom)| serde_json::json!({ + "left": left, + "top": top, + "right": right, + "bottom": bottom, + })), + "isPlaceholder": image.is_placeholder, + "lockAspectRatio": image.lock_aspect_ratio, + "bbox": [image.frame.left, image.frame.top, image.frame.width, image.frame.height], + "bboxUnit": "points", + }), + PresentationElement::Table(table) => serde_json::json!({ + "kind": "table", + "id": format!("tb/{}", table.element_id), + "elementId": table.element_id, + "slide": slide_index + 1, + "slideIndex": slide_index, + "rows": table.rows.len(), + "cols": table.rows.iter().map(std::vec::Vec::len).max().unwrap_or(0), + "columnWidths": table.column_widths, + "rowHeights": table.row_heights, + "style": table.style, + "styleOptions": table_style_options_to_proto(&table.style_options), + "borders": table.borders.as_ref().map(table_borders_to_proto), + "rightToLeft": table.right_to_left, + "cellTextStyles": table + .rows + .iter() + .map(|row| row.iter().map(|cell| text_style_to_proto(&cell.text_style)).collect::>()) + .collect::>(), + "rowsData": table + .rows + .iter() + .map(|row| row.iter().map(table_cell_to_proto).collect::>()) + .collect::>(), + "bbox": [table.frame.left, table.frame.top, table.frame.width, table.frame.height], + "bboxUnit": "points", + }), + PresentationElement::Chart(chart) => serde_json::json!({ + "kind": "chart", + "id": format!("ch/{}", chart.element_id), + "elementId": chart.element_id, + "slide": slide_index + 1, + "slideIndex": slide_index, + "chartType": format!("{:?}", chart.chart_type), + "title": chart.title, + "styleIndex": chart.style_index, + "hasLegend": chart.has_legend, + "legend": chart.legend.as_ref().map(chart_legend_to_proto), + "xAxis": chart.x_axis.as_ref().map(chart_axis_to_proto), + "yAxis": chart.y_axis.as_ref().map(chart_axis_to_proto), + "dataLabels": chart.data_labels.as_ref().map(chart_data_labels_to_proto), + "chartFill": chart.chart_fill, + "plotAreaFill": chart.plot_area_fill, + "series": chart + .series + .iter() + .map(|series| serde_json::json!({ + "name": series.name, + "values": series.values, + "categories": series.categories, + "xValues": series.x_values, + "fill": series.fill, + "stroke": series.stroke.as_ref().map(stroke_to_proto), + "marker": series.marker.as_ref().map(chart_marker_to_proto), + "dataLabelOverrides": series + .data_label_overrides + .iter() + .map(chart_data_label_override_to_proto) + .collect::>(), + })) + .collect::>(), + "bbox": [chart.frame.left, chart.frame.top, chart.frame.width, chart.frame.height], + "bboxUnit": "points", + }), + }; + if let Some(hyperlink) = match element { + PresentationElement::Text(text) => text.hyperlink.as_ref(), + PresentationElement::Shape(shape) => shape.hyperlink.as_ref(), + PresentationElement::Connector(_) + | PresentationElement::Image(_) + | PresentationElement::Table(_) + | PresentationElement::Chart(_) => None, + } { + record["hyperlink"] = hyperlink.to_json(); + } + if let PresentationElement::Shape(shape) = element + && let Some(stroke) = &shape.stroke + { + record["stroke"] = serde_json::json!({ + "color": stroke.color, + "width": stroke.width, + "style": stroke.style.as_api_str(), + }); + } + if let Some(placeholder) = match element { + PresentationElement::Text(text) => text.placeholder.as_ref(), + PresentationElement::Shape(shape) => shape.placeholder.as_ref(), + PresentationElement::Image(image) => image.placeholder.as_ref(), + PresentationElement::Connector(_) + | PresentationElement::Table(_) + | PresentationElement::Chart(_) => None, + } { + record["placeholder"] = Value::String(placeholder.placeholder_type.clone()); + record["placeholderName"] = Value::String(placeholder.name.clone()); + record["placeholderIndex"] = + placeholder.index.map(Value::from).unwrap_or(Value::Null); + } + if record.get("id").and_then(Value::as_str) == Some(id) { + return Ok(record); + } + if let Some(range_id) = id.strip_prefix("tr/") { + match element { + PresentationElement::Text(text) => { + if let Some(range) = + text.rich_text.ranges.iter().find(|range| range.range_id == range_id) + { + let mut range_record = text_range_to_proto(&text.text, range); + range_record["kind"] = Value::String("textRange".to_string()); + range_record["id"] = Value::String(id.to_string()); + range_record["slide"] = Value::from(slide_index + 1); + range_record["slideIndex"] = Value::from(slide_index); + range_record["hostAnchor"] = + Value::String(format!("sh/{}", text.element_id)); + range_record["hostKind"] = Value::String("textbox".to_string()); + return Ok(range_record); + } + } + PresentationElement::Shape(shape) => { + if let Some((text, rich_text)) = + shape.text.as_ref().zip(shape.rich_text.as_ref()) + && let Some(range) = + rich_text.ranges.iter().find(|range| range.range_id == range_id) + { + let mut range_record = text_range_to_proto(text, range); + range_record["kind"] = Value::String("textRange".to_string()); + range_record["id"] = Value::String(id.to_string()); + range_record["slide"] = Value::from(slide_index + 1); + range_record["slideIndex"] = Value::from(slide_index); + range_record["hostAnchor"] = + Value::String(format!("sh/{}", shape.element_id)); + range_record["hostKind"] = Value::String("textbox".to_string()); + return Ok(range_record); + } + } + PresentationElement::Table(table) => { + for (row_index, row) in table.rows.iter().enumerate() { + for (column_index, cell) in row.iter().enumerate() { + if let Some(range) = cell + .rich_text + .ranges + .iter() + .find(|range| range.range_id == range_id) + { + let mut range_record = text_range_to_proto(&cell.text, range); + range_record["kind"] = Value::String("textRange".to_string()); + range_record["id"] = Value::String(id.to_string()); + range_record["slide"] = Value::from(slide_index + 1); + range_record["slideIndex"] = Value::from(slide_index); + range_record["hostAnchor"] = Value::String(format!( + "tb/{}#cell/{row_index}/{column_index}", + table.element_id + )); + range_record["hostKind"] = + Value::String("tableCell".to_string()); + return Ok(range_record); + } + } + } + } + PresentationElement::Connector(_) + | PresentationElement::Image(_) + | PresentationElement::Chart(_) => {} + } + } + } + } + + if let Some(thread_id) = id.strip_prefix("th/") + && let Some(thread) = document + .comment_threads + .iter() + .find(|thread| thread.thread_id == thread_id) + { + let mut record = comment_thread_to_proto(thread); + record["id"] = Value::String(id.to_string()); + return Ok(record); + } + + for layout in &document.layouts { + let layout_id = format!("ly/{}", layout.layout_id); + if id == layout_id { + return Ok(serde_json::json!({ + "kind": "layout", + "id": layout_id, + "layoutId": layout.layout_id, + "name": layout.name, + "type": match layout.kind { + LayoutKind::Layout => "layout", + LayoutKind::Master => "master", + }, + "parentLayoutId": layout.parent_layout_id, + "placeholders": layout_placeholder_list(document, &layout.layout_id, action)?, + })); + } + } + + Err(PresentationArtifactError::UnsupportedFeature { + action: action.to_string(), + message: format!("unknown resolve id `{id}`"), + }) +} diff --git a/codex-rs/artifact-presentation/src/presentation_artifact/manager.rs b/codex-rs/artifact-presentation/src/presentation_artifact/manager.rs new file mode 100644 index 0000000000..8539b62c76 --- /dev/null +++ b/codex-rs/artifact-presentation/src/presentation_artifact/manager.rs @@ -0,0 +1,3203 @@ +#[derive(Debug, Default)] +pub struct PresentationArtifactManager { + documents: HashMap, + undo_stack: Vec, + redo_stack: Vec, +} + +#[derive(Debug, Clone)] +struct HistoryEntry { + artifact_id: String, + action: String, + before: Option, + after: Option, +} + +impl PresentationArtifactManager { + pub fn execute_requests( + &mut self, + request: PresentationArtifactExecutionRequest, + cwd: &Path, + ) -> Result { + let PresentationArtifactExecutionRequest { + artifact_id, + requests, + } = request; + let request_count = requests.len(); + let mut current_artifact_id = artifact_id; + let mut executed_actions = Vec::with_capacity(request_count); + let mut exported_paths = Vec::new(); + let mut last_response = None; + + for mut request in requests { + if request.artifact_id.is_none() { + request.artifact_id = current_artifact_id.clone(); + } + let response = self.execute(request, cwd)?; + current_artifact_id = Some(response.artifact_id.clone()); + exported_paths.extend(response.exported_paths.iter().cloned()); + executed_actions.push(response.action.clone()); + last_response = Some(response); + } + + let mut response = last_response.ok_or_else(|| PresentationArtifactError::InvalidArgs { + action: "presentation_artifact".to_string(), + message: "request sequence must contain at least one action".to_string(), + })?; + if request_count > 1 { + let final_summary = response.summary.clone(); + response.action = "batch".to_string(); + response.summary = + format!("Executed {request_count} actions sequentially. {final_summary}"); + response.executed_actions = Some(executed_actions); + response.exported_paths = exported_paths; + } + Ok(response) + } + + pub fn execute( + &mut self, + request: PresentationArtifactRequest, + cwd: &Path, + ) -> Result { + match request.action.as_str() { + "undo" => return self.undo(request), + "redo" => return self.redo(request), + "record_patch" => return self.record_patch(request), + "apply_patch" => return self.apply_patch(request, cwd), + _ => {} + } + + let action = request.action.clone(); + let before = if tracks_history(&action) { + request + .artifact_id + .as_ref() + .and_then(|artifact_id| self.documents.get(artifact_id).cloned()) + } else { + None + }; + let response = self.execute_action(request, cwd)?; + if tracks_history(&action) { + let after = self.documents.get(&response.artifact_id).cloned(); + self.push_history_entry(HistoryEntry { + artifact_id: response.artifact_id.clone(), + action, + before, + after, + }); + } + Ok(response) + } + + fn execute_action( + &mut self, + request: PresentationArtifactRequest, + cwd: &Path, + ) -> Result { + match request.action.as_str() { + "create" => self.create(request), + "import_pptx" => self.import_pptx(request, cwd), + "export_pptx" => self.export_pptx(request, cwd), + "export_preview" => self.export_preview(request, cwd), + "get_summary" => self.get_summary(request), + "list_slides" => self.list_slides(request), + "list_layouts" => self.list_layouts(request), + "list_layout_placeholders" => self.list_layout_placeholders(request), + "list_slide_placeholders" => self.list_slide_placeholders(request), + "inspect" => self.inspect(request), + "resolve" => self.resolve(request), + "to_proto" => self.proto_snapshot(request), + "add_slide" => self.add_slide(request), + "insert_slide" => self.insert_slide(request), + "duplicate_slide" => self.duplicate_slide(request), + "move_slide" => self.move_slide(request), + "delete_slide" => self.delete_slide(request), + "create_layout" => self.create_layout(request), + "add_layout_placeholder" => self.add_layout_placeholder(request), + "set_slide_layout" => self.set_slide_layout(request), + "update_placeholder_text" => self.update_placeholder_text(request), + "set_theme" => self.set_theme(request), + "add_style" => self.add_style(request), + "get_style" => self.get_style(request), + "describe_styles" => self.describe_styles(request), + "set_notes" => self.set_notes(request), + "set_notes_rich_text" => self.set_notes_rich_text(request), + "append_notes" => self.append_notes(request), + "clear_notes" => self.clear_notes(request), + "set_notes_visibility" => self.set_notes_visibility(request), + "set_active_slide" => self.set_active_slide(request), + "set_slide_background" => self.set_slide_background(request), + "add_text_shape" => self.add_text_shape(request), + "add_shape" => self.add_shape(request), + "add_connector" => self.add_connector(request), + "add_image" => self.add_image(request, cwd), + "replace_image" => self.replace_image(request, cwd), + "add_table" => self.add_table(request), + "update_table_style" => self.update_table_style(request), + "style_table_block" => self.style_table_block(request), + "update_table_cell" => self.update_table_cell(request), + "merge_table_cells" => self.merge_table_cells(request), + "add_chart" => self.add_chart(request), + "update_chart" => self.update_chart(request), + "add_chart_series" => self.add_chart_series(request), + "update_text" => self.update_text(request), + "set_rich_text" => self.set_rich_text(request), + "format_text_range" => self.format_text_range(request), + "replace_text" => self.replace_text(request), + "insert_text_after" => self.insert_text_after(request), + "set_hyperlink" => self.set_hyperlink(request), + "set_comment_author" => self.set_comment_author(request), + "add_comment_thread" => self.add_comment_thread(request), + "add_comment_reply" => self.add_comment_reply(request), + "toggle_comment_reaction" => self.toggle_comment_reaction(request), + "resolve_comment_thread" => self.resolve_comment_thread(request), + "reopen_comment_thread" => self.reopen_comment_thread(request), + "update_shape_style" => self.update_shape_style(request), + "bring_to_front" => self.bring_to_front(request), + "send_to_back" => self.send_to_back(request), + "delete_element" => self.delete_element(request), + "delete_artifact" => self.delete_artifact(request), + other => Err(PresentationArtifactError::UnknownAction(other.to_string())), + } + } + + fn push_history_entry(&mut self, entry: HistoryEntry) { + self.undo_stack.push(entry); + self.redo_stack.clear(); + } + + fn create( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: CreateArgs = parse_args(&request.action, &request.args)?; + let mut document = PresentationDocument::new(args.name); + if let Some(slide_size) = args.slide_size { + document.slide_size = parse_slide_size(&slide_size, &request.action)?; + } + if let Some(theme) = args.theme { + document.theme = normalize_theme(theme, &request.action)?; + } + let artifact_id = document.artifact_id.clone(); + let summary = format!( + "Created presentation artifact `{artifact_id}` with {} slides", + document.slides.len() + ); + let snapshot = snapshot_for_document(&document); + let mut response = + PresentationArtifactResponse::new(artifact_id, request.action, summary, snapshot); + response.theme = Some(document.theme_snapshot()); + self.documents + .insert(response.artifact_id.clone(), document); + Ok(response) + } + + fn import_pptx( + &mut self, + request: PresentationArtifactRequest, + cwd: &Path, + ) -> Result { + let args: ImportPptxArgs = parse_args(&request.action, &request.args)?; + let path = resolve_path(cwd, &args.path); + let document = if let Some(document) = import_codex_metadata_document(&path) + .map_err(|message| PresentationArtifactError::ImportFailed { + path: path.clone(), + message, + })? + { + document + } else { + let imported = Presentation::from_path(&path).map_err(|error| { + PresentationArtifactError::ImportFailed { + path: path.clone(), + message: error.to_string(), + } + })?; + let mut document = PresentationDocument::from_ppt_rs(imported); + import_pptx_images(&path, &mut document, &request.action)?; + document + }; + let artifact_id = document.artifact_id.clone(); + let slide_count = document.slides.len(); + let snapshot = snapshot_for_document(&document); + self.documents.insert(artifact_id.clone(), document); + let summary = format!( + "Imported `{}` as artifact `{artifact_id}` with {slide_count} slides", + path.display() + ); + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + summary, + snapshot, + )) + } + + fn export_pptx( + &mut self, + request: PresentationArtifactRequest, + cwd: &Path, + ) -> Result { + let args: ExportPptxArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document(&artifact_id, &request.action)?; + let path = resolve_path(cwd, &args.path); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|error| { + PresentationArtifactError::ExportFailed { + path: path.clone(), + message: error.to_string(), + } + })?; + } + + let bytes = build_pptx_bytes(document, &request.action).map_err(|message| { + PresentationArtifactError::ExportFailed { + path: path.clone(), + message, + } + })?; + std::fs::write(&path, bytes).map_err(|error| PresentationArtifactError::ExportFailed { + path: path.clone(), + message: error.to_string(), + })?; + + let mut response = PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Exported presentation to `{}`", path.display()), + snapshot_for_document(document), + ); + response.exported_paths.push(path); + Ok(response) + } + + fn export_preview( + &mut self, + request: PresentationArtifactRequest, + cwd: &Path, + ) -> Result { + let args: ExportPreviewArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document(&artifact_id, &request.action)?; + let output_path = resolve_path(cwd, &args.path); + let preview_format = + parse_preview_output_format(args.format.as_deref(), &output_path, &request.action)?; + let scale = normalize_preview_scale(args.scale, &request.action)?; + let quality = normalize_preview_quality(args.quality, &request.action)?; + if let Some(parent) = output_path.parent() { + std::fs::create_dir_all(parent).map_err(|error| { + PresentationArtifactError::ExportFailed { + path: output_path.clone(), + message: error.to_string(), + } + })?; + } + let temp_dir = + std::env::temp_dir().join(format!("presentation_preview_{}", Uuid::new_v4().simple())); + std::fs::create_dir_all(&temp_dir).map_err(|error| { + PresentationArtifactError::ExportFailed { + path: output_path.clone(), + message: error.to_string(), + } + })?; + let preview_document = if let Some(slide_index) = args.slide_index { + let slide = document + .slides + .get(slide_index as usize) + .cloned() + .ok_or_else(|| { + index_out_of_range(&request.action, slide_index as usize, document.slides.len()) + })?; + let slide_id = slide.slide_id.clone(); + PresentationDocument { + artifact_id: document.artifact_id.clone(), + name: document.name.clone(), + slide_size: document.slide_size, + theme: document.theme.clone(), + custom_text_styles: document.custom_text_styles.clone(), + layouts: Vec::new(), + slides: vec![slide], + active_slide_index: Some(0), + comment_self: document.comment_self.clone(), + comment_threads: document + .comment_threads + .iter() + .filter(|thread| match &thread.target { + CommentTarget::Slide { slide_id: target_slide_id } + | CommentTarget::Element { slide_id: target_slide_id, .. } + | CommentTarget::TextRange { slide_id: target_slide_id, .. } => { + target_slide_id == &slide_id + } + }) + .cloned() + .collect(), + next_slide_seq: 1, + next_element_seq: 1, + next_layout_seq: 1, + next_text_range_seq: document.next_text_range_seq, + next_comment_thread_seq: document.next_comment_thread_seq, + next_comment_message_seq: document.next_comment_message_seq, + } + } else { + document.clone() + }; + write_preview_images(&preview_document, &temp_dir, &request.action)?; + let mut exported_paths = collect_pngs(&temp_dir)?; + if args.slide_index.is_some() { + let rendered = + exported_paths + .pop() + .ok_or_else(|| PresentationArtifactError::ExportFailed { + path: output_path.clone(), + message: "preview renderer produced no images".to_string(), + })?; + write_preview_image( + &rendered, + &output_path, + preview_format, + scale, + quality, + &request.action, + )?; + exported_paths = vec![output_path]; + } else { + std::fs::create_dir_all(&output_path).map_err(|error| { + PresentationArtifactError::ExportFailed { + path: output_path.clone(), + message: error.to_string(), + } + })?; + let mut relocated = Vec::new(); + for rendered in exported_paths { + let filename = rendered.file_name().ok_or_else(|| { + PresentationArtifactError::ExportFailed { + path: output_path.clone(), + message: "rendered preview had no filename".to_string(), + } + })?; + let stem = Path::new(filename) + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("preview"); + let target = output_path.join(format!("{stem}.{}", preview_format.extension())); + write_preview_image( + &rendered, + &target, + preview_format, + scale, + quality, + &request.action, + )?; + relocated.push(target); + } + exported_paths = relocated; + } + let mut response = PresentationArtifactResponse::new( + artifact_id, + request.action, + "Exported slide preview".to_string(), + snapshot_for_document(document), + ); + response.exported_paths = exported_paths; + Ok(response) + } + + fn get_summary( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document(&artifact_id, &request.action)?; + let mut response = PresentationArtifactResponse::new( + artifact_id, + request.action, + format!( + "Presentation `{}` has {} slides, {} elements, {} layouts, and active slide {}", + document.name.as_deref().unwrap_or("Untitled"), + document.slides.len(), + document.total_element_count(), + document.layouts.len(), + document + .active_slide_index + .map(|index| index.to_string()) + .unwrap_or_else(|| "none".to_string()) + ), + snapshot_for_document(document), + ); + response.slide_list = Some(slide_list(document)); + response.layout_list = Some(layout_list(document)); + response.theme = Some(document.theme_snapshot()); + response.active_slide_index = document.active_slide_index; + Ok(response) + } + + fn list_slides( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document(&artifact_id, &request.action)?; + let mut response = PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Listed {} slides", document.slides.len()), + snapshot_for_document(document), + ); + response.slide_list = Some(slide_list(document)); + response.theme = Some(document.theme_snapshot()); + response.active_slide_index = document.active_slide_index; + Ok(response) + } + + fn list_layouts( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document(&artifact_id, &request.action)?; + let mut response = PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Listed {} layouts", document.layouts.len()), + snapshot_for_document(document), + ); + response.layout_list = Some(layout_list(document)); + response.theme = Some(document.theme_snapshot()); + Ok(response) + } + + fn list_layout_placeholders( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: LayoutIdArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document(&artifact_id, &request.action)?; + let placeholders = layout_placeholder_list(document, &args.layout_id, &request.action)?; + let mut response = PresentationArtifactResponse::new( + artifact_id, + request.action, + format!( + "Listed {} placeholders for layout `{}`", + placeholders.len(), + args.layout_id + ), + snapshot_for_document(document), + ); + response.placeholder_list = Some(placeholders); + response.layout_list = Some(layout_list(document)); + Ok(response) + } + + fn list_slide_placeholders( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: SlideIndexArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document(&artifact_id, &request.action)?; + let slide_index = args.slide_index as usize; + let slide = document.slides.get(slide_index).ok_or_else(|| { + index_out_of_range(&request.action, slide_index, document.slides.len()) + })?; + let placeholders = slide_placeholder_list(slide, slide_index); + let mut response = PresentationArtifactResponse::new( + artifact_id, + request.action, + format!( + "Listed {} placeholders for slide {}", + placeholders.len(), + args.slide_index + ), + snapshot_for_document(document), + ); + response.placeholder_list = Some(placeholders); + response.slide_list = Some(slide_list(document)); + Ok(response) + } + + fn inspect( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: InspectArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document(&artifact_id, &request.action)?; + let inspect_ndjson = inspect_document(document, &args); + let mut response = PresentationArtifactResponse::new( + artifact_id, + request.action, + "Generated inspection snapshot".to_string(), + snapshot_for_document(document), + ); + response.inspect_ndjson = Some(inspect_ndjson); + response.theme = Some(document.theme_snapshot()); + response.active_slide_index = document.active_slide_index; + Ok(response) + } + + fn resolve( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: ResolveArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document(&artifact_id, &request.action)?; + let resolved_record = resolve_anchor(document, &args.id, &request.action)?; + let mut response = PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Resolved `{}`", args.id), + snapshot_for_document(document), + ); + response.resolved_record = Some(resolved_record); + response.active_slide_index = document.active_slide_index; + Ok(response) + } + + fn proto_snapshot( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document(&artifact_id, &request.action)?; + let mut response = PresentationArtifactResponse::new( + artifact_id, + request.action, + "Generated proto snapshot".to_string(), + snapshot_for_document(document), + ); + response.proto_json = Some(document_to_proto(document, "to_proto")?); + response.theme = Some(document.theme_snapshot()); + response.active_slide_index = document.active_slide_index; + Ok(response) + } + + fn record_patch( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: RecordPatchArgs = parse_args(&request.action, &request.args)?; + let patch = self.normalize_patch( + request.artifact_id.as_deref(), + args.operations, + &request.action, + )?; + let document = self.get_document(&patch.artifact_id, &request.action)?; + let mut response = PresentationArtifactResponse::new( + patch.artifact_id.clone(), + request.action, + format!("Recorded patch with {} operations", patch.operations.len()), + snapshot_for_document(document), + ); + response.patch = Some(serde_json::to_value(&patch).map_err(|error| { + PresentationArtifactError::InvalidArgs { + action: "record_patch".to_string(), + message: format!("failed to serialize patch: {error}"), + } + })?); + response.active_slide_index = document.active_slide_index; + Ok(response) + } + + fn apply_patch( + &mut self, + request: PresentationArtifactRequest, + _cwd: &Path, + ) -> Result { + let args: ApplyPatchArgs = parse_args(&request.action, &request.args)?; + let patch = if let Some(patch) = args.patch { + self.normalize_serialized_patch(request.artifact_id.as_deref(), patch, &request.action)? + } else { + let operations = + args.operations + .ok_or_else(|| PresentationArtifactError::InvalidArgs { + action: request.action.clone(), + message: "provide either `patch` or `operations`".to_string(), + })?; + self.normalize_patch(request.artifact_id.as_deref(), operations, &request.action)? + }; + let before = self.documents.get(&patch.artifact_id).cloned(); + let Some(before_document) = before else { + return Err(PresentationArtifactError::UnknownArtifactId { + action: request.action, + artifact_id: patch.artifact_id, + }); + }; + for operation in &patch.operations { + let nested_request = PresentationArtifactRequest { + artifact_id: Some(patch.artifact_id.clone()), + action: operation.action.clone(), + args: operation.args.clone(), + }; + if let Err(error) = self.execute_action(nested_request, Path::new(".")) { + self.documents + .insert(patch.artifact_id.clone(), before_document); + return Err(error); + } + } + let document = self + .get_document(&patch.artifact_id, &request.action)? + .clone(); + self.push_history_entry(HistoryEntry { + artifact_id: patch.artifact_id.clone(), + action: request.action.clone(), + before: Some(before_document), + after: Some(document.clone()), + }); + let mut response = PresentationArtifactResponse::new( + patch.artifact_id.clone(), + request.action, + format!("Applied patch with {} operations", patch.operations.len()), + snapshot_for_document(&document), + ); + response.patch = Some(serde_json::to_value(&patch).map_err(|error| { + PresentationArtifactError::InvalidArgs { + action: "apply_patch".to_string(), + message: format!("failed to serialize patch: {error}"), + } + })?); + response.active_slide_index = document.active_slide_index; + Ok(response) + } + + fn undo( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let position = self + .undo_stack + .iter() + .rposition(|entry| { + request + .artifact_id + .as_deref() + .is_none_or(|artifact_id| artifact_id == entry.artifact_id) + }) + .ok_or_else(|| PresentationArtifactError::UnsupportedFeature { + action: request.action.clone(), + message: "nothing to undo".to_string(), + })?; + let entry = self.undo_stack.remove(position); + match &entry.before { + Some(document) => { + self.documents + .insert(entry.artifact_id.clone(), document.clone()); + } + None => { + self.documents.remove(&entry.artifact_id); + } + } + self.redo_stack.push(entry.clone()); + Ok(response_for_document_state( + entry.artifact_id, + request.action, + format!("Undid `{}`", entry.action), + entry.before.as_ref(), + )) + } + + fn redo( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let position = self + .redo_stack + .iter() + .rposition(|entry| { + request + .artifact_id + .as_deref() + .is_none_or(|artifact_id| artifact_id == entry.artifact_id) + }) + .ok_or_else(|| PresentationArtifactError::UnsupportedFeature { + action: request.action.clone(), + message: "nothing to redo".to_string(), + })?; + let entry = self.redo_stack.remove(position); + match &entry.after { + Some(document) => { + self.documents + .insert(entry.artifact_id.clone(), document.clone()); + } + None => { + self.documents.remove(&entry.artifact_id); + } + } + self.undo_stack.push(entry.clone()); + Ok(response_for_document_state( + entry.artifact_id, + request.action, + format!("Redid `{}`", entry.action), + entry.after.as_ref(), + )) + } + + fn create_layout( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: CreateLayoutArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let layout_id = document.next_layout_id(); + let kind = match args.kind.as_deref() { + Some("master") => LayoutKind::Master, + Some("layout") | None => LayoutKind::Layout, + Some(other) => { + return Err(PresentationArtifactError::InvalidArgs { + action: request.action, + message: format!("unsupported layout kind `{other}`"), + }); + } + }; + let parent_layout_id = args + .parent_layout_id + .map(|parent_layout_ref| { + document + .get_layout(&parent_layout_ref, &request.action) + .map(|layout| layout.layout_id.clone()) + }) + .transpose()?; + document.layouts.push(LayoutDocument { + layout_id: layout_id.clone(), + name: args.name, + kind, + parent_layout_id, + placeholders: Vec::new(), + }); + let mut response = PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Created layout `{layout_id}`"), + snapshot_for_document(document), + ); + response.layout_list = Some(layout_list(document)); + Ok(response) + } + + fn add_layout_placeholder( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: AddLayoutPlaceholderArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let geometry = args + .geometry + .as_deref() + .map(|value| parse_shape_geometry(value, &request.action)) + .transpose()? + .unwrap_or(ShapeGeometry::Rectangle); + let frame = args.position.unwrap_or(PositionArgs { + left: 48, + top: 72, + width: 624, + height: 96, + rotation: None, + flip_horizontal: None, + flip_vertical: None, + }); + let layout_id = document + .get_layout(&args.layout_id, &request.action)? + .layout_id + .clone(); + let layout = document + .layouts + .iter_mut() + .find(|layout| layout.layout_id == layout_id) + .ok_or_else(|| PresentationArtifactError::UnsupportedFeature { + action: request.action.clone(), + message: format!("unknown layout id `{}`", args.layout_id), + })?; + layout.placeholders.push(PlaceholderDefinition { + name: args.name, + placeholder_type: args.placeholder_type, + index: args.index, + text: args.text, + geometry, + frame: frame.into(), + }); + let mut response = PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Added placeholder to layout `{}`", layout.layout_id), + snapshot_for_document(document), + ); + response.layout_list = Some(layout_list(document)); + Ok(response) + } + + fn set_slide_layout( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: SetSlideLayoutArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let layout = document + .get_layout(&args.layout_id, &request.action)? + .clone(); + let placeholders = + resolved_layout_placeholders(document, &layout.layout_id, &request.action)? + .into_iter() + .map(|resolved| resolved.definition) + .collect::>(); + let mut placeholder_elements = Vec::new(); + for placeholder in placeholders { + placeholder_elements.push(materialize_placeholder_element( + document.next_element_id(), + placeholder, + placeholder_elements.len(), + )); + } + let slide = document.get_slide_mut(args.slide_index, &request.action)?; + slide.elements.retain(|element| match element { + PresentationElement::Text(text) => text.placeholder.is_none(), + PresentationElement::Shape(shape) => shape.placeholder.is_none(), + PresentationElement::Image(image) => image.placeholder.is_none(), + _ => true, + }); + slide.layout_id = Some(layout.layout_id.clone()); + slide.elements.extend(placeholder_elements); + resequence_z_order(slide); + let mut response = PresentationArtifactResponse::new( + artifact_id, + request.action, + format!( + "Applied layout `{}` to slide {}", + layout.layout_id, args.slide_index + ), + snapshot_for_document(document), + ); + response.slide_list = Some(slide_list(document)); + response.layout_list = Some(layout_list(document)); + Ok(response) + } + + fn update_placeholder_text( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: UpdatePlaceholderTextArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let slide = document.get_slide_mut(args.slide_index, &request.action)?; + let target_name = args.name.to_ascii_lowercase(); + let element = slide + .elements + .iter_mut() + .find(|element| match element { + PresentationElement::Text(text) => text + .placeholder + .as_ref() + .map(|placeholder| placeholder.name.eq_ignore_ascii_case(&target_name)) + .unwrap_or(false), + PresentationElement::Shape(shape) => shape + .placeholder + .as_ref() + .map(|placeholder| placeholder.name.eq_ignore_ascii_case(&target_name)) + .unwrap_or(false), + _ => false, + }) + .ok_or_else(|| PresentationArtifactError::UnsupportedFeature { + action: request.action.clone(), + message: format!( + "placeholder `{}` was not found on slide {}", + args.name, args.slide_index + ), + })?; + match element { + PresentationElement::Text(text) => text.text = args.text, + PresentationElement::Shape(shape) => shape.text = Some(args.text), + PresentationElement::Connector(_) + | PresentationElement::Image(_) + | PresentationElement::Table(_) + | PresentationElement::Chart(_) => {} + } + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!( + "Updated placeholder `{}` on slide {}", + args.name, args.slide_index + ), + snapshot_for_document(document), + )) + } + + fn set_theme( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: ThemeArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + document.theme = normalize_theme(args, &request.action)?; + let mut response = PresentationArtifactResponse::new( + artifact_id, + request.action, + "Updated theme".to_string(), + snapshot_for_document(document), + ); + response.theme = Some(document.theme_snapshot()); + Ok(response) + } + + fn add_style( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: AddStyleArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let style_name = normalize_style_name(&args.name, &request.action)?; + let mut style = + normalize_text_style_with_document(document, &args.styling, &request.action)?; + style.style_name = Some(style_name.clone()); + let style_record = NamedTextStyle { + name: style_name.clone(), + style: style.clone(), + built_in: false, + }; + document + .custom_text_styles + .insert(style_name.clone(), style); + let mut response = PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Added text style `{style_name}`"), + snapshot_for_document(document), + ); + response.resolved_record = Some(named_text_style_to_json(&style_record, "st")); + Ok(response) + } + + fn get_style( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: StyleNameArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document(&artifact_id, &request.action)?; + let normalized_style_name = normalize_style_name(&args.name, &request.action)?; + let named_style = document + .named_text_styles() + .into_iter() + .find(|style| style.name == normalized_style_name) + .ok_or_else(|| PresentationArtifactError::UnsupportedFeature { + action: request.action.clone(), + message: format!("unknown text style `{}`", args.name), + })?; + let mut response = PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Resolved text style `{}`", args.name), + snapshot_for_document(document), + ); + response.resolved_record = Some(named_text_style_to_json(&named_style, "st")); + Ok(response) + } + + fn describe_styles( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document(&artifact_id, &request.action)?; + let mut response = PresentationArtifactResponse::new( + artifact_id, + request.action, + "Described text styles".to_string(), + snapshot_for_document(document), + ); + response.resolved_record = Some(serde_json::json!({ + "kind": "styleList", + "styles": document + .named_text_styles() + .iter() + .map(|style| named_text_style_to_json(style, "st")) + .collect::>(), + })); + Ok(response) + } + + fn set_notes( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: NotesArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let slide = document.get_slide_mut(args.slide_index, &request.action)?; + slide.notes.text = args.text.unwrap_or_default(); + slide.notes.rich_text = RichTextState::default(); + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Updated notes for slide {}", args.slide_index), + snapshot_for_document(document), + )) + } + + fn set_notes_rich_text( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: SetRichTextArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let slide_index = args.slide_index.ok_or_else(|| PresentationArtifactError::InvalidArgs { + action: request.action.clone(), + message: "`slide_index` is required for notes rich text".to_string(), + })?; + let (text, mut rich_text) = + normalize_rich_text_input(document, args.text, &request.action)?; + rich_text.layout = normalize_text_layout(&args.text_layout, &request.action)?; + let slide = document.get_slide_mut(slide_index, &request.action)?; + slide.notes.text = text; + slide.notes.rich_text = rich_text; + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Updated rich notes for slide {slide_index}"), + snapshot_for_document(document), + )) + } + + fn append_notes( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: NotesArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let slide = document.get_slide_mut(args.slide_index, &request.action)?; + let text = args.text.unwrap_or_default(); + if slide.notes.text.is_empty() { + slide.notes.text = text; + } else { + slide.notes.text = format!("{}\n{text}", slide.notes.text); + } + slide.notes.rich_text = RichTextState::default(); + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Appended notes for slide {}", args.slide_index), + snapshot_for_document(document), + )) + } + + fn clear_notes( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: NotesArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let slide = document.get_slide_mut(args.slide_index, &request.action)?; + slide.notes.text.clear(); + slide.notes.rich_text = RichTextState::default(); + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Cleared notes for slide {}", args.slide_index), + snapshot_for_document(document), + )) + } + + fn set_notes_visibility( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: NotesVisibilityArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let slide = document.get_slide_mut(args.slide_index, &request.action)?; + slide.notes.visible = args.visible; + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Updated notes visibility for slide {}", args.slide_index), + snapshot_for_document(document), + )) + } + + fn set_active_slide( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: SetActiveSlideArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + document.set_active_slide_index(args.slide_index as usize, &request.action)?; + let mut response = PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Set active slide to {}", args.slide_index), + snapshot_for_document(document), + ); + response.slide_list = Some(slide_list(document)); + response.active_slide_index = document.active_slide_index; + Ok(response) + } + + fn add_slide( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: AddSlideArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let mut slide = document.new_slide(args.notes, args.background_fill, &request.action)?; + if let Some(layout_id) = args.layout { + apply_layout_to_slide(document, &mut slide, &layout_id, &request.action)?; + } + let index = document.append_slide(slide); + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Added slide at index {index}"), + snapshot_for_document(document), + )) + } + + fn insert_slide( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: InsertSlideArgs = parse_args(&request.action, &request.args)?; + if args.index.is_some() && args.after_slide_index.is_some() { + return Err(PresentationArtifactError::InvalidArgs { + action: request.action, + message: "provide at most one of `index` or `after_slide_index`".to_string(), + }); + } + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let index = if let Some(index) = args.index { + to_index(index)? + } else if let Some(after_slide_index) = args.after_slide_index { + after_slide_index as usize + 1 + } else { + document + .active_slide_index + .map(|active_slide_index| active_slide_index + 1) + .unwrap_or(document.slides.len()) + }; + if index > document.slides.len() { + return Err(index_out_of_range( + &request.action, + index, + document.slides.len(), + )); + } + let mut slide = document.new_slide(args.notes, args.background_fill, &request.action)?; + if let Some(layout_id) = args.layout { + apply_layout_to_slide(document, &mut slide, &layout_id, &request.action)?; + } + document.adjust_active_slide_for_insert(index); + document.slides.insert(index, slide); + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Inserted slide at index {index}"), + snapshot_for_document(document), + )) + } + + fn duplicate_slide( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: SlideIndexArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let source = document + .slides + .get(args.slide_index as usize) + .cloned() + .ok_or_else(|| { + index_out_of_range( + &request.action, + args.slide_index as usize, + document.slides.len(), + ) + })?; + let duplicated = document.clone_slide(source); + let insert_at = args.slide_index as usize + 1; + document.adjust_active_slide_for_insert(insert_at); + document.slides.insert(insert_at, duplicated); + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Duplicated slide {} to index {insert_at}", args.slide_index), + snapshot_for_document(document), + )) + } + + fn move_slide( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: MoveSlideArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let from = args.from_index as usize; + let to = args.to_index as usize; + if from >= document.slides.len() { + return Err(index_out_of_range( + &request.action, + from, + document.slides.len(), + )); + } + if to >= document.slides.len() { + return Err(index_out_of_range( + &request.action, + to, + document.slides.len(), + )); + } + let slide = document.slides.remove(from); + document.slides.insert(to, slide); + document.adjust_active_slide_for_move(from, to); + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Moved slide from index {from} to {to}"), + snapshot_for_document(document), + )) + } + + fn delete_slide( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: SlideIndexArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let index = args.slide_index as usize; + if index >= document.slides.len() { + return Err(index_out_of_range( + &request.action, + index, + document.slides.len(), + )); + } + document.slides.remove(index); + document.adjust_active_slide_for_delete(index); + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Deleted slide at index {index}"), + snapshot_for_document(document), + )) + } + + fn set_slide_background( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: SetSlideBackgroundArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let fill = normalize_color_with_document(document, &args.fill, &request.action, "fill")?; + let slide = document.get_slide_mut(args.slide_index, &request.action)?; + slide.background_fill = Some(fill); + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Updated background for slide {}", args.slide_index), + snapshot_for_document(document), + )) + } + + fn add_text_shape( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: AddTextShapeArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let style = normalize_text_style_with_document(document, &args.styling, &request.action)?; + let fill = args + .styling + .fill + .as_deref() + .map(|value| normalize_color_with_document(document, value, &request.action, "fill")) + .transpose()?; + let text_layout = normalize_text_layout(&args.text_layout, &request.action)?; + let element_id = document.next_element_id(); + let slide = document.get_slide_mut(args.slide_index, &request.action)?; + slide.elements.push(PresentationElement::Text(TextElement { + element_id: element_id.clone(), + text: args.text, + frame: args.position.into(), + fill, + style, + hyperlink: None, + rich_text: RichTextState { + ranges: Vec::new(), + layout: text_layout, + }, + placeholder: None, + z_order: slide.elements.len(), + })); + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!( + "Added text element `{element_id}` to slide {}", + args.slide_index + ), + snapshot_for_document(document), + )) + } + + fn add_shape( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: AddShapeArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let text_style = + normalize_text_style_with_document(document, &args.text_style, &request.action)?; + let fill = args + .fill + .as_deref() + .map(|value| normalize_color_with_document(document, value, &request.action, "fill")) + .transpose()?; + let stroke = parse_stroke(document, args.stroke, &request.action)?; + let text_layout = normalize_text_layout(&args.text_layout, &request.action)?; + let rich_text = args.text.as_ref().map(|_| RichTextState { + ranges: Vec::new(), + layout: text_layout, + }); + let element_id = document.next_element_id(); + let slide = document.get_slide_mut(args.slide_index, &request.action)?; + slide + .elements + .push(PresentationElement::Shape(ShapeElement { + element_id: element_id.clone(), + geometry: parse_shape_geometry(&args.geometry, &request.action)?, + frame: args.position.clone().into(), + fill, + stroke, + text: args.text, + text_style, + hyperlink: None, + rich_text, + placeholder: None, + rotation_degrees: args.rotation.or(args.position.rotation), + flip_horizontal: args + .flip_horizontal + .or(args.position.flip_horizontal) + .unwrap_or(false), + flip_vertical: args + .flip_vertical + .or(args.position.flip_vertical) + .unwrap_or(false), + z_order: slide.elements.len(), + })); + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!( + "Added shape element `{element_id}` to slide {}", + args.slide_index + ), + snapshot_for_document(document), + )) + } + + fn add_connector( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: AddConnectorArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let element_id = document.next_element_id(); + let line = parse_connector_line(document, args.line, &request.action)?; + let slide = document.get_slide_mut(args.slide_index, &request.action)?; + slide + .elements + .push(PresentationElement::Connector(ConnectorElement { + element_id: element_id.clone(), + connector_type: parse_connector_kind(&args.connector_type, &request.action)?, + start: args.start, + end: args.end, + line: StrokeStyle { + color: line.color, + width: line.width, + style: LineStyle::Solid, + }, + line_style: line.style, + start_arrow: args + .start_arrow + .as_deref() + .map(|value| parse_connector_arrow(value, &request.action)) + .transpose()? + .unwrap_or(ConnectorArrowKind::None), + end_arrow: args + .end_arrow + .as_deref() + .map(|value| parse_connector_arrow(value, &request.action)) + .transpose()? + .unwrap_or(ConnectorArrowKind::None), + arrow_size: args + .arrow_size + .as_deref() + .map(|value| parse_connector_arrow_size(value, &request.action)) + .transpose()? + .unwrap_or(ConnectorArrowScale::Medium), + label: args.label, + z_order: slide.elements.len(), + })); + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!( + "Added connector element `{element_id}` to slide {}", + args.slide_index + ), + snapshot_for_document(document), + )) + } + + fn add_image( + &mut self, + request: PresentationArtifactRequest, + cwd: &Path, + ) -> Result { + let args: AddImageArgs = parse_args(&request.action, &request.args)?; + let image_source = args.image_source()?; + let is_placeholder = matches!(image_source, ImageInputSource::Placeholder); + let image_payload = match image_source { + ImageInputSource::Path(path) => Some(load_image_payload_from_path( + &resolve_path(cwd, &path), + &request.action, + )?), + ImageInputSource::DataUrl(data_url) => Some(load_image_payload_from_data_url( + &data_url, + &request.action, + )?), + ImageInputSource::Blob(blob) => { + Some(load_image_payload_from_blob(&blob, &request.action)?) + } + ImageInputSource::Uri(uri) => Some(load_image_payload_from_uri(&uri, &request.action)?), + ImageInputSource::Placeholder => None, + }; + let fit_mode = args.fit.unwrap_or(ImageFitMode::Stretch); + let lock_aspect_ratio = args + .lock_aspect_ratio + .unwrap_or(fit_mode != ImageFitMode::Stretch); + let crop = args + .crop + .map(|crop| normalize_image_crop(crop, &request.action)) + .transpose()?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let element_id = document.next_element_id(); + let slide = document.get_slide_mut(args.slide_index, &request.action)?; + slide + .elements + .push(PresentationElement::Image(ImageElement { + element_id: element_id.clone(), + frame: args.position.clone().into(), + payload: image_payload, + fit_mode, + crop, + rotation_degrees: args.rotation.or(args.position.rotation), + flip_horizontal: args + .flip_horizontal + .or(args.position.flip_horizontal) + .unwrap_or(false), + flip_vertical: args + .flip_vertical + .or(args.position.flip_vertical) + .unwrap_or(false), + lock_aspect_ratio, + alt_text: args.alt, + prompt: args.prompt, + is_placeholder, + placeholder: None, + z_order: slide.elements.len(), + })); + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!( + "Added image element `{element_id}` to slide {}", + args.slide_index + ), + snapshot_for_document(document), + )) + } + + fn replace_image( + &mut self, + request: PresentationArtifactRequest, + cwd: &Path, + ) -> Result { + let args: ReplaceImageArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let image_source = match ( + &args.path, + &args.data_url, + &args.blob, + &args.uri, + &args.prompt, + ) { + (Some(path), None, None, None, None) => ImageInputSource::Path(path.clone()), + (None, Some(data_url), None, None, None) => ImageInputSource::DataUrl(data_url.clone()), + (None, None, Some(blob), None, None) => ImageInputSource::Blob(blob.clone()), + (None, None, None, Some(uri), None) => ImageInputSource::Uri(uri.clone()), + (None, None, None, None, Some(_)) => ImageInputSource::Placeholder, + _ => { + return Err(PresentationArtifactError::InvalidArgs { + action: request.action, + message: + "provide exactly one of `path`, `data_url`, `blob`, or `uri`, or provide `prompt` for a placeholder image" + .to_string(), + }); + } + }; + let is_placeholder = matches!(image_source, ImageInputSource::Placeholder); + let image_payload = match image_source { + ImageInputSource::Path(path) => Some(load_image_payload_from_path( + &resolve_path(cwd, &path), + "replace_image", + )?), + ImageInputSource::DataUrl(data_url) => Some(load_image_payload_from_data_url( + &data_url, + "replace_image", + )?), + ImageInputSource::Blob(blob) => { + Some(load_image_payload_from_blob(&blob, "replace_image")?) + } + ImageInputSource::Uri(uri) => Some(load_image_payload_from_uri(&uri, "replace_image")?), + ImageInputSource::Placeholder => None, + }; + let fit_mode = args.fit.unwrap_or(ImageFitMode::Stretch); + let lock_aspect_ratio = args + .lock_aspect_ratio + .unwrap_or(fit_mode != ImageFitMode::Stretch); + let crop = args + .crop + .map(|crop| normalize_image_crop(crop, &request.action)) + .transpose()?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let element = document.find_element_mut(&args.element_id, &request.action)?; + let PresentationElement::Image(image) = element else { + return Err(PresentationArtifactError::UnsupportedFeature { + action: request.action, + message: format!("element `{}` is not an image", args.element_id), + }); + }; + image.payload = image_payload; + image.fit_mode = fit_mode; + image.crop = crop; + if let Some(rotation) = args.rotation { + image.rotation_degrees = Some(rotation); + } + if let Some(flip_horizontal) = args.flip_horizontal { + image.flip_horizontal = flip_horizontal; + } + if let Some(flip_vertical) = args.flip_vertical { + image.flip_vertical = flip_vertical; + } + image.lock_aspect_ratio = lock_aspect_ratio; + image.alt_text = args.alt; + image.prompt = args.prompt; + image.is_placeholder = is_placeholder; + Ok(PresentationArtifactResponse::new( + artifact_id, + "replace_image".to_string(), + format!("Replaced image `{}`", args.element_id), + snapshot_for_document(document), + )) + } + + fn add_table( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: AddTableArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let rows = coerce_table_rows(args.rows, &request.action)?; + let borders = parse_table_borders(document, args.borders, &request.action)?; + let style_options = parse_table_style_options(args.style_options); + let mut frame: Rect = args.position.into(); + let (column_widths, row_heights) = normalize_table_dimensions( + &rows, + frame, + args.column_widths, + args.row_heights, + &request.action, + )?; + frame.width = column_widths.iter().sum(); + frame.height = row_heights.iter().sum(); + let element_id = document.next_element_id(); + let slide = document.get_slide_mut(args.slide_index, &request.action)?; + slide + .elements + .push(PresentationElement::Table(TableElement { + element_id: element_id.clone(), + frame, + rows, + column_widths, + row_heights, + style: args.style, + style_options, + borders, + right_to_left: args.right_to_left.unwrap_or(false), + merges: Vec::new(), + z_order: slide.elements.len(), + })); + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!( + "Added table element `{element_id}` to slide {}", + args.slide_index + ), + snapshot_for_document(document), + )) + } + + fn update_table_cell( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: UpdateTableCellArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let text_style = + normalize_text_style_with_document(document, &args.styling, &request.action)?; + let background_fill = args + .background_fill + .as_deref() + .map(|fill| { + normalize_color_with_document(document, fill, &request.action, "background_fill") + }) + .transpose()?; + let element = document.find_element_mut(&args.element_id, &request.action)?; + let PresentationElement::Table(table) = element else { + return Err(PresentationArtifactError::UnsupportedFeature { + action: request.action, + message: format!("element `{}` is not a table", args.element_id), + }); + }; + let row = args.row as usize; + let column = args.column as usize; + if row >= table.rows.len() || column >= table.rows[row].len() { + return Err(PresentationArtifactError::InvalidArgs { + action: request.action, + message: format!("cell ({row}, {column}) is out of bounds"), + }); + } + let cell = &mut table.rows[row][column]; + cell.text = cell_value_to_string(args.value); + cell.text_style = text_style; + cell.background_fill = background_fill; + cell.rich_text = RichTextState::default(); + cell.alignment = args + .alignment + .as_deref() + .map(|value| parse_alignment(value, &request.action)) + .transpose()?; + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Updated table cell ({row}, {column})"), + snapshot_for_document(document), + )) + } + + fn update_table_style( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: UpdateTableStyleArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let borders = parse_table_borders(document, args.borders, &request.action)?; + let element = document.find_element_mut(&args.element_id, &request.action)?; + let PresentationElement::Table(table) = element else { + return Err(PresentationArtifactError::UnsupportedFeature { + action: request.action, + message: format!("element `{}` is not a table", args.element_id), + }); + }; + table.style = args.style; + if let Some(borders) = borders { + if let Some(existing) = table.borders.as_mut() { + if borders.outside.is_some() { + existing.outside = borders.outside; + } + if borders.inside.is_some() { + existing.inside = borders.inside; + } + if borders.top.is_some() { + existing.top = borders.top; + } + if borders.bottom.is_some() { + existing.bottom = borders.bottom; + } + if borders.left.is_some() { + existing.left = borders.left; + } + if borders.right.is_some() { + existing.right = borders.right; + } + } else { + table.borders = Some(borders); + } + } + if let Some(style_options) = args.style_options { + if let Some(value) = style_options.header_row { + table.style_options.header_row = value; + } + if let Some(value) = style_options.banded_rows { + table.style_options.banded_rows = value; + } + if let Some(value) = style_options.banded_columns { + table.style_options.banded_columns = value; + } + if let Some(value) = style_options.first_column { + table.style_options.first_column = value; + } + if let Some(value) = style_options.last_column { + table.style_options.last_column = value; + } + if let Some(value) = style_options.total_row { + table.style_options.total_row = value; + } + } + if let Some(right_to_left) = args.right_to_left { + table.right_to_left = right_to_left; + } + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Updated table style for `{}`", args.element_id), + snapshot_for_document(document), + )) + } + + fn style_table_block( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: StyleTableBlockArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let text_style = + normalize_text_style_with_document(document, &args.styling, &request.action)?; + let background_fill = args + .background_fill + .as_deref() + .map(|fill| { + normalize_color_with_document(document, fill, &request.action, "background_fill") + }) + .transpose()?; + let borders = parse_table_borders(document, args.borders, &request.action)?; + let alignment = args + .alignment + .as_deref() + .map(|value| parse_alignment(value, &request.action)) + .transpose()?; + let element = document.find_element_mut(&args.element_id, &request.action)?; + let PresentationElement::Table(table) = element else { + return Err(PresentationArtifactError::UnsupportedFeature { + action: request.action, + message: format!("element `{}` is not a table", args.element_id), + }); + }; + let end_row = (args.row + args.row_count) as usize; + let end_column = (args.column + args.column_count) as usize; + for row_index in args.row as usize..end_row { + if row_index >= table.rows.len() { + break; + } + for column_index in args.column as usize..end_column { + if column_index >= table.rows[row_index].len() { + break; + } + let cell = &mut table.rows[row_index][column_index]; + cell.text_style = text_style.clone(); + if let Some(fill) = background_fill.clone() { + cell.background_fill = Some(fill); + } + if let Some(alignment) = alignment { + cell.alignment = Some(alignment); + } + if let Some(borders) = borders.clone() { + cell.borders = Some(borders); + } + } + } + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Styled table block for `{}`", args.element_id), + snapshot_for_document(document), + )) + } + + fn merge_table_cells( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: MergeTableCellsArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let element = document.find_element_mut(&args.element_id, &request.action)?; + let PresentationElement::Table(table) = element else { + return Err(PresentationArtifactError::UnsupportedFeature { + action: request.action, + message: format!("element `{}` is not a table", args.element_id), + }); + }; + let region = TableMergeRegion { + start_row: args.start_row as usize, + end_row: args.end_row as usize, + start_column: args.start_column as usize, + end_column: args.end_column as usize, + }; + table.merges.push(region); + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Merged table cells in `{}`", args.element_id), + snapshot_for_document(document), + )) + } + + fn add_chart( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: AddChartArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let chart_type = parse_chart_type(&args.chart_type, &request.action)?; + let series = parse_chart_series(document, args.series, &request.action)?; + let legend_text_style = + normalize_text_style_with_document(document, &args.legend_text_style, &request.action)?; + let data_labels = parse_chart_data_labels(document, args.data_labels, &request.action)?; + let chart_fill = args + .chart_fill + .as_deref() + .map(|value| { + normalize_color_with_document(document, value, &request.action, "chart_fill") + }) + .transpose()?; + let plot_area_fill = args + .plot_area_fill + .as_deref() + .map(|value| { + normalize_color_with_document(document, value, &request.action, "plot_area_fill") + }) + .transpose()?; + let element_id = document.next_element_id(); + let slide = document.get_slide_mut(args.slide_index, &request.action)?; + slide + .elements + .push(PresentationElement::Chart(ChartElement { + element_id: element_id.clone(), + frame: args.position.into(), + chart_type, + categories: args.categories, + series, + title: args.title, + style_index: args.style_index, + has_legend: args.has_legend.unwrap_or(false), + legend: Some(ChartLegend { + position: args.legend_position, + text_style: legend_text_style, + }), + x_axis: Some(ChartAxisSpec { + title: args.x_axis_title, + }), + y_axis: Some(ChartAxisSpec { + title: args.y_axis_title, + }), + data_labels, + chart_fill, + plot_area_fill, + z_order: slide.elements.len(), + })); + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!( + "Added chart element `{element_id}` to slide {}", + args.slide_index + ), + snapshot_for_document(document), + )) + } + + fn update_chart( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: UpdateChartArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let legend_text_style = + normalize_text_style_with_document(document, &args.legend_text_style, &request.action)?; + let data_labels = parse_chart_data_labels(document, args.data_labels, &request.action)?; + let chart_fill = args + .chart_fill + .as_deref() + .map(|value| normalize_color_with_document(document, value, &request.action, "chart_fill")) + .transpose()?; + let plot_area_fill = args + .plot_area_fill + .as_deref() + .map(|value| normalize_color_with_document(document, value, &request.action, "plot_area_fill")) + .transpose()?; + let element = document.find_element_mut(&args.element_id, &request.action)?; + let PresentationElement::Chart(chart) = element else { + return Err(PresentationArtifactError::UnsupportedFeature { + action: request.action, + message: format!("element `{}` is not a chart", args.element_id), + }); + }; + if let Some(title) = args.title { + chart.title = Some(title); + } + if let Some(categories) = args.categories { + chart.categories = categories; + } + if let Some(style_index) = args.style_index { + chart.style_index = Some(style_index); + } + if let Some(has_legend) = args.has_legend { + chart.has_legend = has_legend; + } + if args.legend_position.is_some() || !text_style_is_empty(&legend_text_style) { + chart.legend = Some(ChartLegend { + position: args.legend_position, + text_style: legend_text_style, + }); + } + if args.x_axis_title.is_some() { + chart.x_axis = Some(ChartAxisSpec { + title: args.x_axis_title, + }); + } + if args.y_axis_title.is_some() { + chart.y_axis = Some(ChartAxisSpec { + title: args.y_axis_title, + }); + } + if data_labels.is_some() { + chart.data_labels = data_labels; + } + if chart_fill.is_some() { + chart.chart_fill = chart_fill; + } + if plot_area_fill.is_some() { + chart.plot_area_fill = plot_area_fill; + } + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Updated chart `{}`", args.element_id), + snapshot_for_document(document), + )) + } + + fn add_chart_series( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: AddChartSeriesArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let series = parse_chart_series( + document, + vec![ChartSeriesArgs { + name: args.name, + values: args.values, + categories: args.categories, + x_values: args.x_values, + fill: args.fill, + stroke: args.stroke, + marker: args.marker, + data_label_overrides: None, + }], + &request.action, + )?; + let element = document.find_element_mut(&args.element_id, &request.action)?; + let PresentationElement::Chart(chart) = element else { + return Err(PresentationArtifactError::UnsupportedFeature { + action: request.action, + message: format!("element `{}` is not a chart", args.element_id), + }); + }; + chart.series.extend(series); + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Added chart series to `{}`", args.element_id), + snapshot_for_document(document), + )) + } + + fn update_text( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: UpdateTextArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let style = normalize_text_style_with_document(document, &args.styling, &request.action)?; + let fill = args + .styling + .fill + .as_deref() + .map(|value| normalize_color_with_document(document, value, &request.action, "fill")) + .transpose()?; + let text_layout = normalize_text_layout(&args.text_layout, &request.action)?; + let element = document.find_element_mut(&args.element_id, &request.action)?; + match element { + PresentationElement::Text(text) => { + text.text = args.text; + if let Some(fill) = fill.clone() { + text.fill = Some(fill); + } + text.style = style; + text.rich_text = RichTextState { + ranges: Vec::new(), + layout: text_layout, + }; + } + PresentationElement::Shape(shape) => { + if shape.text.is_none() { + return Err(PresentationArtifactError::UnsupportedFeature { + action: request.action, + message: format!( + "element `{}` does not contain editable text", + args.element_id + ), + }); + } + shape.text = Some(args.text); + if let Some(fill) = fill { + shape.fill = Some(fill); + } + shape.text_style = style; + shape.rich_text = Some(RichTextState { + ranges: Vec::new(), + layout: text_layout, + }); + } + other => { + return Err(PresentationArtifactError::UnsupportedFeature { + action: request.action, + message: format!( + "element `{}` is `{}`; only text-bearing elements support `update_text`", + args.element_id, + other.kind() + ), + }); + } + } + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Updated text for element `{}`", args.element_id), + snapshot_for_document(document), + )) + } + + fn set_rich_text( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: SetRichTextArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let (text, mut rich_text) = + normalize_rich_text_input(document, args.text, &request.action)?; + rich_text.layout = normalize_text_layout(&args.text_layout, &request.action)?; + let style = normalize_text_style_with_document(document, &args.styling, &request.action)?; + if args.notes.unwrap_or(false) { + let slide_index = args.slide_index.ok_or_else(|| PresentationArtifactError::InvalidArgs { + action: request.action.clone(), + message: "`slide_index` is required for notes rich text".to_string(), + })?; + let slide = document.get_slide_mut(slide_index, &request.action)?; + slide.notes.text = text; + slide.notes.rich_text = rich_text; + return Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Updated rich text for notes on slide {slide_index}"), + snapshot_for_document(document), + )); + } + if let Some(element_id) = args.element_id { + if let (Some(row), Some(column)) = (args.row, args.column) { + let element = document.find_element_mut(&element_id, &request.action)?; + let PresentationElement::Table(table) = element else { + return Err(PresentationArtifactError::UnsupportedFeature { + action: request.action, + message: format!("element `{element_id}` is not a table"), + }); + }; + let row = row as usize; + let column = column as usize; + if row >= table.rows.len() || column >= table.rows[row].len() { + return Err(PresentationArtifactError::InvalidArgs { + action: request.action, + message: format!("cell ({row}, {column}) is out of bounds"), + }); + } + let cell = &mut table.rows[row][column]; + cell.text = text; + cell.text_style = style; + cell.rich_text = rich_text; + return Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Updated rich text for table cell ({row}, {column})"), + snapshot_for_document(document), + )); + } + let element = document.find_element_mut(&element_id, &request.action)?; + match element { + PresentationElement::Text(text_element) => { + text_element.text = text; + text_element.style = style; + text_element.rich_text = rich_text; + } + PresentationElement::Shape(shape) => { + if shape.text.is_none() { + return Err(PresentationArtifactError::UnsupportedFeature { + action: request.action, + message: format!( + "element `{element_id}` does not contain editable text" + ), + }); + } + shape.text = Some(text); + shape.text_style = style; + shape.rich_text = Some(rich_text); + } + other => { + return Err(PresentationArtifactError::UnsupportedFeature { + action: request.action, + message: format!( + "element `{element_id}` is `{}`; only text-bearing elements support `set_rich_text`", + other.kind() + ), + }); + } + } + return Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Updated rich text for element `{element_id}`"), + snapshot_for_document(document), + )); + } + Err(PresentationArtifactError::InvalidArgs { + action: request.action, + message: "provide `element_id` or `slide_index` with `notes: true`".to_string(), + }) + } + + fn format_text_range( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: FormatTextRangeArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let style = normalize_text_style_with_document(document, &args.styling, &request.action)?; + let hyperlink = args + .link + .as_ref() + .map(|link| parse_rich_text_link(link, &request.action)) + .transpose()?; + let layout = normalize_text_layout(&args.text_layout, &request.action)?; + let apply_annotation = + |range_id: String, + text: &str, + rich_text: &mut RichTextState| + -> Result<(), PresentationArtifactError> { + let (start_cp, length, _) = resolve_text_range_selector( + text, + args.query.as_deref(), + args.occurrence, + args.start_cp, + args.length, + &request.action, + )?; + rich_text.ranges.push(TextRangeAnnotation { + range_id, + start_cp, + length, + style: style.clone(), + hyperlink: hyperlink.clone(), + spacing_before: args.spacing_before.map(|value| value * 100), + spacing_after: args.spacing_after.map(|value| value * 100), + line_spacing: args.line_spacing, + }); + if layout.insets.is_some() + || layout.wrap.is_some() + || layout.auto_fit.is_some() + || layout.vertical_alignment.is_some() + { + rich_text.layout = layout.clone(); + } + Ok(()) + }; + if args.notes.unwrap_or(false) { + let slide_index = args.slide_index.ok_or_else(|| PresentationArtifactError::InvalidArgs { + action: request.action.clone(), + message: "`slide_index` is required for notes text ranges".to_string(), + })?; + let range_id = document.next_text_range_id(); + let slide = document.get_slide_mut(slide_index, &request.action)?; + apply_annotation(range_id, &slide.notes.text, &mut slide.notes.rich_text)?; + return Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Formatted notes text range on slide {slide_index}"), + snapshot_for_document(document), + )); + } + let element_id = args.element_id.clone().ok_or_else(|| PresentationArtifactError::InvalidArgs { + action: request.action.clone(), + message: "`element_id` is required unless formatting notes".to_string(), + })?; + if let (Some(row), Some(column)) = (args.row, args.column) { + let range_id = document.next_text_range_id(); + let element = document.find_element_mut(&element_id, &request.action)?; + let PresentationElement::Table(table) = element else { + return Err(PresentationArtifactError::UnsupportedFeature { + action: request.action, + message: format!("element `{element_id}` is not a table"), + }); + }; + let row = row as usize; + let column = column as usize; + if row >= table.rows.len() || column >= table.rows[row].len() { + return Err(PresentationArtifactError::InvalidArgs { + action: request.action, + message: format!("cell ({row}, {column}) is out of bounds"), + }); + } + let cell = &mut table.rows[row][column]; + apply_annotation(range_id, &cell.text, &mut cell.rich_text)?; + return Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Formatted table cell text range ({row}, {column})"), + snapshot_for_document(document), + )); + } + let range_id = document.next_text_range_id(); + let element = document.find_element_mut(&element_id, &request.action)?; + match element { + PresentationElement::Text(text) => { + apply_annotation(range_id, &text.text, &mut text.rich_text)?; + } + PresentationElement::Shape(shape) => { + let text_value = shape.text.as_ref().ok_or_else(|| { + PresentationArtifactError::UnsupportedFeature { + action: request.action.clone(), + message: format!("element `{element_id}` does not contain editable text"), + } + })?; + let rich_text = shape.rich_text.get_or_insert_with(RichTextState::default); + apply_annotation(range_id, text_value, rich_text)?; + } + other => { + return Err(PresentationArtifactError::UnsupportedFeature { + action: request.action, + message: format!( + "element `{element_id}` is `{}`; only text-bearing elements support `format_text_range`", + other.kind() + ), + }); + } + } + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Formatted text range on element `{element_id}`"), + snapshot_for_document(document), + )) + } + + fn replace_text( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: ReplaceTextArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let element = document.find_element_mut(&args.element_id, &request.action)?; + match element { + PresentationElement::Text(text) => { + if !text.text.contains(&args.search) { + return Err(PresentationArtifactError::InvalidArgs { + action: request.action, + message: format!( + "text `{}` was not found in element `{}`", + args.search, args.element_id + ), + }); + } + text.text = text.text.replace(&args.search, &args.replace); + } + PresentationElement::Shape(shape) => { + let Some(text) = &mut shape.text else { + return Err(PresentationArtifactError::UnsupportedFeature { + action: request.action, + message: format!( + "element `{}` does not contain editable text", + args.element_id + ), + }); + }; + if !text.contains(&args.search) { + return Err(PresentationArtifactError::InvalidArgs { + action: request.action, + message: format!( + "text `{}` was not found in element `{}`", + args.search, args.element_id + ), + }); + } + *text = text.replace(&args.search, &args.replace); + } + other => { + return Err(PresentationArtifactError::UnsupportedFeature { + action: request.action, + message: format!( + "element `{}` is `{}`; only text-bearing elements support `replace_text`", + args.element_id, + other.kind() + ), + }); + } + } + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Replaced text in element `{}`", args.element_id), + snapshot_for_document(document), + )) + } + + fn insert_text_after( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: InsertTextAfterArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let element = document.find_element_mut(&args.element_id, &request.action)?; + match element { + PresentationElement::Text(text) => { + let Some(index) = text.text.find(&args.after) else { + return Err(PresentationArtifactError::InvalidArgs { + action: request.action, + message: format!( + "text `{}` was not found in element `{}`", + args.after, args.element_id + ), + }); + }; + let insert_at = index + args.after.len(); + text.text.insert_str(insert_at, &args.insert); + } + PresentationElement::Shape(shape) => { + let Some(text) = &mut shape.text else { + return Err(PresentationArtifactError::UnsupportedFeature { + action: request.action, + message: format!( + "element `{}` does not contain editable text", + args.element_id + ), + }); + }; + let Some(index) = text.find(&args.after) else { + return Err(PresentationArtifactError::InvalidArgs { + action: request.action, + message: format!( + "text `{}` was not found in element `{}`", + args.after, args.element_id + ), + }); + }; + let insert_at = index + args.after.len(); + text.insert_str(insert_at, &args.insert); + } + other => { + return Err(PresentationArtifactError::UnsupportedFeature { + action: request.action, + message: format!( + "element `{}` is `{}`; only text-bearing elements support `insert_text_after`", + args.element_id, + other.kind() + ), + }); + } + } + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Inserted text in element `{}`", args.element_id), + snapshot_for_document(document), + )) + } + + fn set_hyperlink( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: SetHyperlinkArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let clear = args.clear.unwrap_or(false); + let hyperlink = if clear { + None + } else { + Some(parse_hyperlink_state(document, &args, &request.action)?) + }; + let element = document.find_element_mut(&args.element_id, &request.action)?; + match element { + PresentationElement::Text(text) => text.hyperlink = hyperlink, + PresentationElement::Shape(shape) => shape.hyperlink = hyperlink, + other => { + return Err(PresentationArtifactError::UnsupportedFeature { + action: request.action, + message: format!( + "element `{}` is `{}`; only text boxes and shapes support `set_hyperlink`", + args.element_id, + other.kind() + ), + }); + } + } + Ok(PresentationArtifactResponse::new( + artifact_id, + "set_hyperlink".to_string(), + if clear { + format!("Cleared hyperlink for element `{}`", args.element_id) + } else { + format!("Updated hyperlink for element `{}`", args.element_id) + }, + snapshot_for_document(document), + )) + } + + fn set_comment_author( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: SetCommentAuthorArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + document.comment_self = Some(CommentAuthorProfile { + display_name: args.display_name, + initials: args.initials, + email: args.email, + }); + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + "Updated comment author".to_string(), + snapshot_for_document(document), + )) + } + + fn add_comment_thread( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: AddCommentThreadArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let author = document.comment_self.clone().ok_or_else(|| { + PresentationArtifactError::InvalidArgs { + action: request.action.clone(), + message: "set a comment author first with `set_comment_author`".to_string(), + } + })?; + let target = if let Some(slide_index) = args.slide_index { + let slide = document.get_slide_mut(slide_index, &request.action)?; + if let Some(element_id) = args.element_id { + if args.query.is_some() || args.start_cp.is_some() { + let (text, _rich_text) = lookup_text_target( + slide, + &element_id, + None, + None, + &request.action, + )?; + let (start_cp, length, context) = resolve_text_range_selector( + text, + args.query.as_deref(), + args.occurrence, + args.start_cp, + args.length, + &request.action, + )?; + CommentTarget::TextRange { + slide_id: slide.slide_id.clone(), + element_id: normalize_element_lookup_id(&element_id).to_string(), + start_cp, + length, + context, + } + } else { + CommentTarget::Element { + slide_id: slide.slide_id.clone(), + element_id: normalize_element_lookup_id(&element_id).to_string(), + } + } + } else { + CommentTarget::Slide { + slide_id: slide.slide_id.clone(), + } + } + } else { + return Err(PresentationArtifactError::InvalidArgs { + action: request.action, + message: "`slide_index` is required for comment threads".to_string(), + }); + }; + let thread_id = document.next_comment_thread_id(); + let message_id = document.next_comment_message_id(); + document.comment_threads.push(CommentThread { + thread_id: thread_id.clone(), + target, + position: args.position.map(|position| CommentPosition { + x: position.x, + y: position.y, + }), + status: CommentThreadStatus::Active, + messages: vec![CommentMessage { + message_id, + author, + text: args.text, + created_at: "2026-03-03T00:00:00Z".to_string(), + reactions: Vec::new(), + }], + }); + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Added comment thread `{thread_id}`"), + snapshot_for_document(document), + )) + } + + fn add_comment_reply( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: AddCommentReplyArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let author = document.comment_self.clone().ok_or_else(|| { + PresentationArtifactError::InvalidArgs { + action: request.action.clone(), + message: "set a comment author first with `set_comment_author`".to_string(), + } + })?; + let message_id = document.next_comment_message_id(); + let thread = document + .comment_threads + .iter_mut() + .find(|thread| thread.thread_id == args.thread_id) + .ok_or_else(|| PresentationArtifactError::InvalidArgs { + action: request.action.clone(), + message: format!("unknown comment thread `{}`", args.thread_id), + })?; + thread.messages.push(CommentMessage { + message_id, + author, + text: args.text, + created_at: "2026-03-03T00:00:00Z".to_string(), + reactions: Vec::new(), + }); + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Added reply to `{}`", args.thread_id), + snapshot_for_document(document), + )) + } + + fn toggle_comment_reaction( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: ToggleCommentReactionArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let thread = document + .comment_threads + .iter_mut() + .find(|thread| thread.thread_id == args.thread_id) + .ok_or_else(|| PresentationArtifactError::InvalidArgs { + action: request.action.clone(), + message: format!("unknown comment thread `{}`", args.thread_id), + })?; + let target_message_id = args + .message_id + .clone() + .or_else(|| thread.messages.last().map(|message| message.message_id.clone())) + .unwrap_or_default(); + let message = thread + .messages + .iter_mut() + .find(|message| message.message_id == target_message_id) + .ok_or_else(|| PresentationArtifactError::InvalidArgs { + action: request.action.clone(), + message: format!("unknown comment message `{target_message_id}`"), + })?; + if let Some(index) = message.reactions.iter().position(|emoji| emoji == &args.emoji) { + message.reactions.remove(index); + } else { + message.reactions.push(args.emoji.clone()); + } + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Toggled reaction on `{}`", args.thread_id), + snapshot_for_document(document), + )) + } + + fn resolve_comment_thread( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: CommentThreadIdArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let thread = document + .comment_threads + .iter_mut() + .find(|thread| thread.thread_id == args.thread_id) + .ok_or_else(|| PresentationArtifactError::InvalidArgs { + action: request.action.clone(), + message: format!("unknown comment thread `{}`", args.thread_id), + })?; + thread.status = CommentThreadStatus::Resolved; + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Resolved `{}`", args.thread_id), + snapshot_for_document(document), + )) + } + + fn reopen_comment_thread( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: CommentThreadIdArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let thread = document + .comment_threads + .iter_mut() + .find(|thread| thread.thread_id == args.thread_id) + .ok_or_else(|| PresentationArtifactError::InvalidArgs { + action: request.action.clone(), + message: format!("unknown comment thread `{}`", args.thread_id), + })?; + thread.status = CommentThreadStatus::Active; + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Reopened `{}`", args.thread_id), + snapshot_for_document(document), + )) + } + + fn update_shape_style( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: UpdateShapeStyleArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let fill = args + .fill + .as_deref() + .map(|value| normalize_color_with_document(document, value, &request.action, "fill")) + .transpose()?; + let stroke = args + .stroke + .clone() + .map(|value| parse_required_stroke(document, value, &request.action)) + .transpose()?; + let text_layout = normalize_text_layout(&args.text_layout, &request.action)?; + let has_text_layout = text_layout.insets.is_some() + || text_layout.wrap.is_some() + || text_layout.auto_fit.is_some() + || text_layout.vertical_alignment.is_some(); + let element = document.find_element_mut(&args.element_id, &request.action)?; + match element { + PresentationElement::Text(text) => { + if let Some(position) = args.position { + text.frame = apply_partial_position(text.frame, position); + } + if let Some(fill) = fill.clone() { + text.fill = Some(fill); + } + if has_text_layout { + text.rich_text.layout = text_layout; + } + if args.stroke.is_some() + || args.rotation.is_some() + || args.flip_horizontal.is_some() + || args.flip_vertical.is_some() + { + return Err(PresentationArtifactError::UnsupportedFeature { + action: request.action, + message: + "text elements support only `position`, `z_order`, and `fill` updates" + .to_string(), + }); + } + } + PresentationElement::Shape(shape) => { + let position_rotation = args + .position + .as_ref() + .and_then(|position| position.rotation); + let position_flip_horizontal = args + .position + .as_ref() + .and_then(|position| position.flip_horizontal); + let position_flip_vertical = args + .position + .as_ref() + .and_then(|position| position.flip_vertical); + if let Some(position) = args.position { + shape.frame = apply_partial_position(shape.frame, position); + } + if let Some(fill) = fill { + shape.fill = Some(fill); + } + if let Some(stroke) = stroke { + shape.stroke = Some(stroke); + } + if let Some(rotation) = args.rotation.or(position_rotation) { + shape.rotation_degrees = Some(rotation); + } + if let Some(flip_horizontal) = args.flip_horizontal.or(position_flip_horizontal) { + shape.flip_horizontal = flip_horizontal; + } + if let Some(flip_vertical) = args.flip_vertical.or(position_flip_vertical) { + shape.flip_vertical = flip_vertical; + } + if shape.text.is_some() && has_text_layout { + shape.rich_text = Some(RichTextState { + ranges: shape.rich_text.take().unwrap_or_default().ranges, + layout: text_layout, + }); + } + } + PresentationElement::Connector(connector) => { + if args.fill.is_some() + || args.rotation.is_some() + || args.flip_horizontal.is_some() + || args.flip_vertical.is_some() + || args.fit.is_some() + || args.crop.is_some() + || args.lock_aspect_ratio.is_some() + { + return Err(PresentationArtifactError::UnsupportedFeature { + action: request.action, + message: + "connector elements support only `position`, `stroke`, and `z_order` updates" + .to_string(), + }); + } + if let Some(position) = args.position { + let updated = apply_partial_position( + Rect { + left: connector.start.left, + top: connector.start.top, + width: connector.end.left.abs_diff(connector.start.left), + height: connector.end.top.abs_diff(connector.start.top), + }, + position, + ); + connector.start = PointArgs { + left: updated.left, + top: updated.top, + }; + connector.end = PointArgs { + left: updated.left.saturating_add(updated.width), + top: updated.top.saturating_add(updated.height), + }; + } + if let Some(stroke) = stroke { + connector.line = stroke; + } + } + PresentationElement::Image(image) => { + let position_rotation = args + .position + .as_ref() + .and_then(|position| position.rotation); + let position_flip_horizontal = args + .position + .as_ref() + .and_then(|position| position.flip_horizontal); + let position_flip_vertical = args + .position + .as_ref() + .and_then(|position| position.flip_vertical); + if args.fill.is_some() || args.stroke.is_some() { + return Err(PresentationArtifactError::UnsupportedFeature { + action: request.action, + message: + "image elements support only `position`, `fit`, `crop`, `rotation`, `flip_horizontal`, `flip_vertical`, `lock_aspect_ratio`, and `z_order` updates" + .to_string(), + }); + } + if let Some(position) = args.position { + image.frame = apply_partial_position_to_image(image, position); + } + if let Some(fit) = args.fit { + image.fit_mode = fit; + if !matches!(fit, ImageFitMode::Stretch) && args.lock_aspect_ratio.is_none() { + image.lock_aspect_ratio = true; + } + } + if let Some(crop) = args.crop { + image.crop = Some(normalize_image_crop(crop, &request.action)?); + } + if let Some(rotation) = args.rotation.or(position_rotation) { + image.rotation_degrees = Some(rotation); + } + if let Some(flip_horizontal) = args.flip_horizontal.or(position_flip_horizontal) { + image.flip_horizontal = flip_horizontal; + } + if let Some(flip_vertical) = args.flip_vertical.or(position_flip_vertical) { + image.flip_vertical = flip_vertical; + } + if let Some(lock_aspect_ratio) = args.lock_aspect_ratio { + image.lock_aspect_ratio = lock_aspect_ratio; + } + } + PresentationElement::Table(table) => { + if args.fill.is_some() + || args.stroke.is_some() + || args.rotation.is_some() + || args.flip_horizontal.is_some() + || args.flip_vertical.is_some() + { + return Err(PresentationArtifactError::UnsupportedFeature { + action: request.action, + message: "table elements support only `position` and `z_order` updates" + .to_string(), + }); + } + if let Some(position) = args.position { + table.frame = apply_partial_position(table.frame, position); + } + } + PresentationElement::Chart(chart) => { + if args.fill.is_some() + || args.stroke.is_some() + || args.rotation.is_some() + || args.flip_horizontal.is_some() + || args.flip_vertical.is_some() + { + return Err(PresentationArtifactError::UnsupportedFeature { + action: request.action, + message: "chart elements support only `position` and `z_order` updates" + .to_string(), + }); + } + if let Some(position) = args.position { + chart.frame = apply_partial_position(chart.frame, position); + } + } + } + if let Some(z_order) = args.z_order { + document.set_z_order(&args.element_id, z_order as usize, &request.action)?; + } + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Updated style for element `{}`", args.element_id), + snapshot_for_document(document), + )) + } + + fn delete_element( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: ElementIdArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + document.remove_element(&args.element_id, &request.action)?; + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Deleted element `{}`", args.element_id), + snapshot_for_document(document), + )) + } + + fn bring_to_front( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: ElementIdArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let target_index = document.total_element_count(); + document.set_z_order(&args.element_id, target_index, &request.action)?; + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Brought `{}` to front", args.element_id), + snapshot_for_document(document), + )) + } + + fn send_to_back( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: ElementIdArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + document.set_z_order(&args.element_id, 0, &request.action)?; + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Sent `{}` to back", args.element_id), + snapshot_for_document(document), + )) + } + + fn delete_artifact( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let artifact_id = required_artifact_id(&request)?; + let removed = self.documents.remove(&artifact_id).ok_or_else(|| { + PresentationArtifactError::UnknownArtifactId { + action: request.action.clone(), + artifact_id: artifact_id.clone(), + } + })?; + Ok(PresentationArtifactResponse { + artifact_id, + action: request.action, + summary: format!( + "Deleted in-memory artifact `{}` with {} slides", + removed.artifact_id, + removed.slides.len() + ), + executed_actions: None, + exported_paths: Vec::new(), + artifact_snapshot: None, + slide_list: None, + layout_list: None, + placeholder_list: None, + theme: None, + inspect_ndjson: None, + resolved_record: None, + proto_json: None, + patch: None, + active_slide_index: None, + }) + } + + fn get_document( + &self, + artifact_id: &str, + action: &str, + ) -> Result<&PresentationDocument, PresentationArtifactError> { + self.documents.get(artifact_id).ok_or_else(|| { + PresentationArtifactError::UnknownArtifactId { + action: action.to_string(), + artifact_id: artifact_id.to_string(), + } + }) + } + + fn get_document_mut( + &mut self, + artifact_id: &str, + action: &str, + ) -> Result<&mut PresentationDocument, PresentationArtifactError> { + self.documents.get_mut(artifact_id).ok_or_else(|| { + PresentationArtifactError::UnknownArtifactId { + action: action.to_string(), + artifact_id: artifact_id.to_string(), + } + }) + } + + fn normalize_patch( + &self, + request_artifact_id: Option<&str>, + operations: Vec, + action: &str, + ) -> Result { + if operations.is_empty() { + return Err(PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: "`operations` must contain at least one entry".to_string(), + }); + } + let mut patch_artifact_id = request_artifact_id.map(str::to_owned); + let mut normalized_operations = Vec::with_capacity(operations.len()); + for operation in operations { + let operation_artifact_id = operation + .artifact_id + .or_else(|| request_artifact_id.map(str::to_owned)) + .ok_or_else(|| PresentationArtifactError::MissingArtifactId { + action: action.to_string(), + })?; + if let Some(existing_artifact_id) = &patch_artifact_id { + if existing_artifact_id != &operation_artifact_id { + return Err(PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: format!( + "patch operations must target a single artifact, found both `{existing_artifact_id}` and `{operation_artifact_id}`" + ), + }); + } + } else { + patch_artifact_id = Some(operation_artifact_id.clone()); + } + if !patch_operation_supported(&operation.action) { + return Err(PresentationArtifactError::UnsupportedFeature { + action: action.to_string(), + message: format!( + "patch operations do not support nested action `{}`", + operation.action + ), + }); + } + normalized_operations.push(PatchOperation { + action: operation.action, + args: operation.args, + }); + } + let artifact_id = + patch_artifact_id.ok_or_else(|| PresentationArtifactError::MissingArtifactId { + action: action.to_string(), + })?; + self.get_document(&artifact_id, action)?; + Ok(PresentationPatch { + version: 1, + artifact_id, + operations: normalized_operations, + }) + } + + fn normalize_serialized_patch( + &self, + request_artifact_id: Option<&str>, + patch: PresentationPatch, + action: &str, + ) -> Result { + if patch.version != 1 { + return Err(PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("unsupported patch version `{}`", patch.version), + }); + } + if patch.operations.is_empty() { + return Err(PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: "`patch.operations` must contain at least one entry".to_string(), + }); + } + if let Some(request_artifact_id) = request_artifact_id + && request_artifact_id != patch.artifact_id + { + return Err(PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: format!( + "request artifact `{request_artifact_id}` does not match patch artifact `{}`", + patch.artifact_id + ), + }); + } + for operation in &patch.operations { + if !patch_operation_supported(&operation.action) { + return Err(PresentationArtifactError::UnsupportedFeature { + action: action.to_string(), + message: format!( + "patch operations do not support nested action `{}`", + operation.action + ), + }); + } + } + self.get_document(&patch.artifact_id, action)?; + Ok(patch) + } +} + +fn lookup_text_target<'a>( + slide: &'a PresentationSlide, + element_id: &str, + _row: Option, + _column: Option, + action: &str, +) -> Result<(&'a str, Option<&'a RichTextState>), PresentationArtifactError> { + let normalized_element_id = normalize_element_lookup_id(element_id); + let element = slide + .elements + .iter() + .find(|element| element.element_id() == normalized_element_id) + .ok_or_else(|| PresentationArtifactError::UnsupportedFeature { + action: action.to_string(), + message: format!("unknown element `{element_id}` on slide `{}`", slide.slide_id), + })?; + match element { + PresentationElement::Text(text) => Ok((&text.text, Some(&text.rich_text))), + PresentationElement::Shape(shape) => shape + .text + .as_deref() + .map(|text| (text, shape.rich_text.as_ref())) + .ok_or_else(|| PresentationArtifactError::UnsupportedFeature { + action: action.to_string(), + message: format!("element `{element_id}` does not contain editable text"), + }), + PresentationElement::Connector(_) + | PresentationElement::Image(_) + | PresentationElement::Table(_) + | PresentationElement::Chart(_) => Err(PresentationArtifactError::UnsupportedFeature { + action: action.to_string(), + message: format!( + "element `{element_id}` does not support text-range comments" + ), + }), + } +} diff --git a/codex-rs/artifact-presentation/src/presentation_artifact/mod.rs b/codex-rs/artifact-presentation/src/presentation_artifact/mod.rs new file mode 100644 index 0000000000..8497062a91 --- /dev/null +++ b/codex-rs/artifact-presentation/src/presentation_artifact/mod.rs @@ -0,0 +1,10 @@ +include!("api.rs"); +include!("manager.rs"); +include!("response.rs"); +include!("model.rs"); +include!("args.rs"); +include!("parsing.rs"); +include!("proto.rs"); +include!("inspect.rs"); +include!("pptx.rs"); +include!("snapshot.rs"); diff --git a/codex-rs/artifact-presentation/src/presentation_artifact/model.rs b/codex-rs/artifact-presentation/src/presentation_artifact/model.rs new file mode 100644 index 0000000000..b5cd5d00a3 --- /dev/null +++ b/codex-rs/artifact-presentation/src/presentation_artifact/model.rs @@ -0,0 +1,1934 @@ +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct ThemeState { + color_scheme: HashMap, + major_font: Option, + minor_font: Option, +} + +impl ThemeState { + fn resolve_color(&self, color: &str) -> Option { + let key = color.trim().to_ascii_lowercase(); + let alias = match key.as_str() { + "background1" => "bg1", + "background2" => "bg2", + "text1" => "tx1", + "text2" => "tx2", + "dark1" => "dk1", + "dark2" => "dk2", + "light1" => "lt1", + "light2" => "lt2", + other => other, + }; + self.color_scheme + .get(alias) + .or_else(|| self.color_scheme.get(&key)) + .cloned() + .map(|value| value.trim_start_matches('#').to_uppercase()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +enum LayoutKind { + Layout, + Master, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct LayoutDocument { + layout_id: String, + name: String, + kind: LayoutKind, + parent_layout_id: Option, + placeholders: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PlaceholderDefinition { + name: String, + placeholder_type: String, + index: Option, + text: Option, + geometry: ShapeGeometry, + frame: Rect, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ResolvedPlaceholder { + source_layout_id: String, + definition: PlaceholderDefinition, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct NotesState { + text: String, + visible: bool, + #[serde(default)] + rich_text: RichTextState, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct TextStyle { + style_name: Option, + font_size: Option, + font_family: Option, + color: Option, + alignment: Option, + bold: bool, + italic: bool, + underline: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct NamedTextStyle { + name: String, + style: TextStyle, + built_in: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct HyperlinkState { + target: HyperlinkTarget, + tooltip: Option, + highlight_click: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +enum HyperlinkTarget { + Url(String), + Slide(u32), + FirstSlide, + LastSlide, + NextSlide, + PreviousSlide, + EndShow, + Email { + address: String, + subject: Option, + }, + File(String), +} + +impl HyperlinkTarget { + fn relationship_target(&self) -> String { + match self { + Self::Url(url) => url.clone(), + Self::Slide(slide_index) => format!("slide{}.xml", slide_index + 1), + Self::FirstSlide => "ppaction://hlinkshowjump?jump=firstslide".to_string(), + Self::LastSlide => "ppaction://hlinkshowjump?jump=lastslide".to_string(), + Self::NextSlide => "ppaction://hlinkshowjump?jump=nextslide".to_string(), + Self::PreviousSlide => "ppaction://hlinkshowjump?jump=previousslide".to_string(), + Self::EndShow => "ppaction://hlinkshowjump?jump=endshow".to_string(), + Self::Email { address, subject } => { + let mut mailto = format!("mailto:{address}"); + if let Some(subject) = subject { + mailto.push_str(&format!("?subject={subject}")); + } + mailto + } + Self::File(path) => format!("file:///{}", path.replace('\\', "/")), + } + } + + fn is_external(&self) -> bool { + matches!(self, Self::Url(_) | Self::Email { .. } | Self::File(_)) + } +} + +impl HyperlinkState { + fn to_ppt_rs(&self, relationship_id: &str) -> PptHyperlink { + let hyperlink = match &self.target { + HyperlinkTarget::Url(url) => PptHyperlink::new(PptHyperlinkAction::url(url)), + HyperlinkTarget::Slide(slide_index) => { + PptHyperlink::new(PptHyperlinkAction::slide(slide_index + 1)) + } + HyperlinkTarget::FirstSlide => PptHyperlink::new(PptHyperlinkAction::FirstSlide), + HyperlinkTarget::LastSlide => PptHyperlink::new(PptHyperlinkAction::LastSlide), + HyperlinkTarget::NextSlide => PptHyperlink::new(PptHyperlinkAction::NextSlide), + HyperlinkTarget::PreviousSlide => PptHyperlink::new(PptHyperlinkAction::PreviousSlide), + HyperlinkTarget::EndShow => PptHyperlink::new(PptHyperlinkAction::EndShow), + HyperlinkTarget::Email { address, subject } => PptHyperlink::new(match subject { + Some(subject) => PptHyperlinkAction::email_with_subject(address, subject), + None => PptHyperlinkAction::email(address), + }), + HyperlinkTarget::File(path) => PptHyperlink::new(PptHyperlinkAction::file(path)), + }; + let hyperlink = if let Some(tooltip) = &self.tooltip { + hyperlink.with_tooltip(tooltip) + } else { + hyperlink + }; + hyperlink + .with_highlight_click(self.highlight_click) + .with_r_id(relationship_id) + } + + fn to_json(&self) -> Value { + let mut record = match &self.target { + HyperlinkTarget::Url(url) => serde_json::json!({ + "type": "url", + "url": url, + }), + HyperlinkTarget::Slide(slide_index) => serde_json::json!({ + "type": "slide", + "slideIndex": slide_index, + }), + HyperlinkTarget::FirstSlide => serde_json::json!({ + "type": "firstSlide", + }), + HyperlinkTarget::LastSlide => serde_json::json!({ + "type": "lastSlide", + }), + HyperlinkTarget::NextSlide => serde_json::json!({ + "type": "nextSlide", + }), + HyperlinkTarget::PreviousSlide => serde_json::json!({ + "type": "previousSlide", + }), + HyperlinkTarget::EndShow => serde_json::json!({ + "type": "endShow", + }), + HyperlinkTarget::Email { address, subject } => serde_json::json!({ + "type": "email", + "address": address, + "subject": subject, + }), + HyperlinkTarget::File(path) => serde_json::json!({ + "type": "file", + "path": path, + }), + }; + record["tooltip"] = self + .tooltip + .as_ref() + .map(|tooltip| Value::String(tooltip.clone())) + .unwrap_or(Value::Null); + record["highlightClick"] = Value::Bool(self.highlight_click); + record + } + + fn relationship_xml(&self, relationship_id: &str) -> String { + let target_mode = if self.target.is_external() { + r#" TargetMode="External""# + } else { + "" + }; + format!( + r#""#, + ppt_rs::escape_xml(&self.target.relationship_target()), + ) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +enum TextAlignment { + Left, + Center, + Right, + Justify, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct RichTextState { + #[serde(default)] + ranges: Vec, + #[serde(default)] + layout: TextLayoutState, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct TextRangeAnnotation { + range_id: String, + start_cp: usize, + length: usize, + style: TextStyle, + hyperlink: Option, + spacing_before: Option, + spacing_after: Option, + line_spacing: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct TextLayoutState { + insets: Option, + wrap: Option, + auto_fit: Option, + vertical_alignment: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +struct TextInsets { + left: u32, + right: u32, + top: u32, + bottom: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +enum TextWrapMode { + Square, + None, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +enum TextAutoFitMode { + None, + ShrinkText, + ResizeShapeToFitText, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +enum TextVerticalAlignment { + Top, + Middle, + Bottom, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CommentAuthorProfile { + display_name: String, + initials: String, + email: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CommentPosition { + x: u32, + y: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +enum CommentThreadStatus { + Active, + Resolved, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CommentMessage { + message_id: String, + author: CommentAuthorProfile, + text: String, + created_at: String, + #[serde(default)] + reactions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +enum CommentTarget { + Slide { + slide_id: String, + }, + Element { + slide_id: String, + element_id: String, + }, + TextRange { + slide_id: String, + element_id: String, + start_cp: usize, + length: usize, + context: Option, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CommentThread { + thread_id: String, + target: CommentTarget, + position: Option, + status: CommentThreadStatus, + messages: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct TableBorder { + color: String, + width: u32, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct TableBorders { + outside: Option, + inside: Option, + top: Option, + bottom: Option, + left: Option, + right: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct TableStyleOptions { + header_row: bool, + banded_rows: bool, + banded_columns: bool, + first_column: bool, + last_column: bool, + total_row: bool, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct ChartMarkerStyle { + symbol: Option, + size: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct ChartDataLabels { + show_value: bool, + show_category_name: bool, + show_leader_lines: bool, + position: Option, + text_style: TextStyle, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct ChartLegend { + position: Option, + text_style: TextStyle, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct ChartAxisSpec { + title: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct ChartDataLabelOverride { + idx: usize, + text: Option, + position: Option, + text_style: TextStyle, + fill: Option, + stroke: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct PlaceholderRef { + name: String, + placeholder_type: String, + index: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct TableMergeRegion { + start_row: usize, + end_row: usize, + start_column: usize, + end_column: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct TableCellSpec { + text: String, + text_style: TextStyle, + background_fill: Option, + alignment: Option, + #[serde(default)] + rich_text: RichTextState, + borders: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PresentationDocument { + artifact_id: String, + name: Option, + slide_size: Rect, + theme: ThemeState, + custom_text_styles: HashMap, + layouts: Vec, + slides: Vec, + active_slide_index: Option, + #[serde(default)] + comment_self: Option, + #[serde(default)] + comment_threads: Vec, + next_slide_seq: u32, + next_element_seq: u32, + next_layout_seq: u32, + next_text_range_seq: u32, + next_comment_thread_seq: u32, + next_comment_message_seq: u32, +} + +impl PresentationDocument { + fn new(name: Option) -> Self { + Self { + artifact_id: format!("presentation_{}", Uuid::new_v4().simple()), + name, + slide_size: Rect { + left: 0, + top: 0, + width: DEFAULT_SLIDE_WIDTH_POINTS, + height: DEFAULT_SLIDE_HEIGHT_POINTS, + }, + theme: ThemeState::default(), + custom_text_styles: HashMap::new(), + layouts: Vec::new(), + slides: Vec::new(), + active_slide_index: None, + comment_self: None, + comment_threads: Vec::new(), + next_slide_seq: 1, + next_element_seq: 1, + next_layout_seq: 1, + next_text_range_seq: 1, + next_comment_thread_seq: 1, + next_comment_message_seq: 1, + } + } + + fn from_ppt_rs(presentation: Presentation) -> Self { + let mut document = Self::new( + (!presentation.get_title().is_empty()).then(|| presentation.get_title().to_string()), + ); + for imported_slide in presentation.slides() { + let mut slide = PresentationSlide { + slide_id: format!("slide_{}", document.next_slide_seq), + notes: NotesState { + text: imported_slide.notes.clone().unwrap_or_default(), + visible: true, + rich_text: RichTextState::default(), + }, + background_fill: None, + layout_id: None, + elements: Vec::new(), + }; + document.next_slide_seq += 1; + + if !imported_slide.title.is_empty() { + slide.elements.push(PresentationElement::Text(TextElement { + element_id: document.next_element_id(), + text: imported_slide.title.clone(), + frame: Rect { + left: DEFAULT_IMPORTED_TITLE_LEFT, + top: DEFAULT_IMPORTED_TITLE_TOP, + width: DEFAULT_IMPORTED_TITLE_WIDTH, + height: DEFAULT_IMPORTED_TITLE_HEIGHT, + }, + fill: None, + style: TextStyle::default(), + hyperlink: None, + rich_text: RichTextState::default(), + placeholder: None, + z_order: slide.elements.len(), + })); + } + + if !imported_slide.content.is_empty() { + slide.elements.push(PresentationElement::Text(TextElement { + element_id: document.next_element_id(), + text: imported_slide.content.join("\n"), + frame: Rect { + left: DEFAULT_IMPORTED_CONTENT_LEFT, + top: DEFAULT_IMPORTED_CONTENT_TOP, + width: DEFAULT_IMPORTED_CONTENT_WIDTH, + height: DEFAULT_IMPORTED_CONTENT_HEIGHT, + }, + fill: None, + style: TextStyle::default(), + hyperlink: None, + rich_text: RichTextState::default(), + placeholder: None, + z_order: slide.elements.len(), + })); + } + + for imported_shape in &imported_slide.shapes { + slide + .elements + .push(PresentationElement::Shape(ShapeElement { + element_id: document.next_element_id(), + geometry: ShapeGeometry::from_shape_type(imported_shape.shape_type), + frame: Rect::from_emu( + imported_shape.x, + imported_shape.y, + imported_shape.width, + imported_shape.height, + ), + fill: imported_shape.fill.as_ref().map(|fill| fill.color.clone()), + stroke: imported_shape.line.as_ref().map(|line| StrokeStyle { + color: line.color.clone(), + width: emu_to_points(line.width), + style: LineStyle::Solid, + }), + text: imported_shape.text.clone(), + text_style: TextStyle::default(), + hyperlink: None, + rich_text: imported_shape + .text + .as_ref() + .map(|_| RichTextState::default()), + placeholder: None, + rotation_degrees: imported_shape.rotation, + flip_horizontal: false, + flip_vertical: false, + z_order: slide.elements.len(), + })); + } + + if let Some(imported_table) = &imported_slide.table { + slide + .elements + .push(PresentationElement::Table(TableElement { + element_id: document.next_element_id(), + frame: Rect::from_emu( + imported_table.x, + imported_table.y, + imported_table.width(), + imported_table.height(), + ), + rows: imported_table + .rows + .iter() + .map(|row| { + row.cells + .iter() + .map(|text| TableCellSpec { + text: text.text.clone(), + text_style: TextStyle::default(), + background_fill: None, + alignment: None, + rich_text: RichTextState::default(), + borders: None, + }) + .collect() + }) + .collect(), + column_widths: imported_table + .column_widths + .iter() + .copied() + .map(emu_to_points) + .collect(), + row_heights: imported_table + .rows + .iter() + .map(|row| row.height.map_or(400_000, |height| height)) + .map(emu_to_points) + .collect(), + style: None, + style_options: TableStyleOptions::default(), + borders: None, + right_to_left: false, + merges: Vec::new(), + z_order: slide.elements.len(), + })); + } + + document.slides.push(slide); + } + document.active_slide_index = (!document.slides.is_empty()).then_some(0); + document + } + + fn new_slide( + &mut self, + notes: Option, + background_fill: Option, + action: &str, + ) -> Result { + let normalized_fill = background_fill + .map(|value| { + normalize_color_with_palette(Some(&self.theme), &value, action, "background_fill") + }) + .transpose()?; + let slide = PresentationSlide { + slide_id: format!("slide_{}", self.next_slide_seq), + notes: NotesState { + text: notes.unwrap_or_default(), + visible: true, + rich_text: RichTextState::default(), + }, + background_fill: normalized_fill, + layout_id: None, + elements: Vec::new(), + }; + self.next_slide_seq += 1; + Ok(slide) + } + + fn append_slide(&mut self, slide: PresentationSlide) -> usize { + let index = self.slides.len(); + self.slides.push(slide); + if self.active_slide_index.is_none() { + self.active_slide_index = Some(index); + } + index + } + + fn clone_slide(&mut self, slide: PresentationSlide) -> PresentationSlide { + let mut clone = slide; + clone.slide_id = format!("slide_{}", self.next_slide_seq); + self.next_slide_seq += 1; + for element in &mut clone.elements { + element.set_element_id(self.next_element_id()); + } + clone + } + + fn next_element_id(&mut self) -> String { + let element_id = format!("element_{}", self.next_element_seq); + self.next_element_seq += 1; + element_id + } + + fn next_text_range_id(&mut self) -> String { + let range_id = format!("range_{}", self.next_text_range_seq); + self.next_text_range_seq += 1; + range_id + } + + fn next_comment_thread_id(&mut self) -> String { + let thread_id = format!("thread_{}", self.next_comment_thread_seq); + self.next_comment_thread_seq += 1; + thread_id + } + + fn next_comment_message_id(&mut self) -> String { + let message_id = format!("message_{}", self.next_comment_message_seq); + self.next_comment_message_seq += 1; + message_id + } + + fn total_element_count(&self) -> usize { + self.slides.iter().map(|slide| slide.elements.len()).sum() + } + + fn set_active_slide_index( + &mut self, + slide_index: usize, + action: &str, + ) -> Result<(), PresentationArtifactError> { + if slide_index >= self.slides.len() { + return Err(index_out_of_range(action, slide_index, self.slides.len())); + } + self.active_slide_index = Some(slide_index); + Ok(()) + } + + fn adjust_active_slide_for_insert(&mut self, inserted_index: usize) { + match self.active_slide_index { + None => self.active_slide_index = Some(inserted_index), + Some(active_index) if inserted_index <= active_index => { + self.active_slide_index = Some(active_index + 1); + } + Some(_) => {} + } + } + + fn adjust_active_slide_for_move(&mut self, from_index: usize, to_index: usize) { + if let Some(active_index) = self.active_slide_index { + self.active_slide_index = Some(if active_index == from_index { + to_index + } else if from_index < active_index && active_index <= to_index { + active_index - 1 + } else if to_index <= active_index && active_index < from_index { + active_index + 1 + } else { + active_index + }); + } + } + + fn adjust_active_slide_for_delete(&mut self, deleted_index: usize) { + self.active_slide_index = match self.active_slide_index { + None => None, + Some(_) if self.slides.is_empty() => None, + Some(active_index) if active_index == deleted_index => { + Some(deleted_index.min(self.slides.len() - 1)) + } + Some(active_index) if deleted_index < active_index => Some(active_index - 1), + Some(active_index) => Some(active_index), + }; + } + + fn next_layout_id(&mut self) -> String { + let layout_id = format!("layout_{}", self.next_layout_seq); + self.next_layout_seq += 1; + layout_id + } + + fn get_layout( + &self, + layout_ref: &str, + action: &str, + ) -> Result<&LayoutDocument, PresentationArtifactError> { + if let Some(layout) = self + .layouts + .iter() + .find(|layout| layout.layout_id == layout_ref) + { + return Ok(layout); + } + + let exact_name_matches = self + .layouts + .iter() + .filter(|layout| layout.name == layout_ref) + .collect::>(); + if exact_name_matches.len() > 1 { + return Err(PresentationArtifactError::UnsupportedFeature { + action: action.to_string(), + message: format!("layout name `{layout_ref}` is ambiguous"), + }); + } + if let Some(layout) = exact_name_matches.into_iter().next() { + return Ok(layout); + } + + let case_insensitive_name_matches = self + .layouts + .iter() + .filter(|layout| layout.name.eq_ignore_ascii_case(layout_ref)) + .collect::>(); + if case_insensitive_name_matches.len() > 1 { + return Err(PresentationArtifactError::UnsupportedFeature { + action: action.to_string(), + message: format!("layout name `{layout_ref}` is ambiguous"), + }); + } + case_insensitive_name_matches + .into_iter() + .next() + .ok_or_else(|| PresentationArtifactError::UnsupportedFeature { + action: action.to_string(), + message: format!("unknown layout id or name `{layout_ref}`"), + }) + } + + fn theme_snapshot(&self) -> ThemeSnapshot { + ThemeSnapshot { + color_scheme: self.theme.color_scheme.clone(), + hex_color_map: self.theme.color_scheme.clone(), + major_font: self.theme.major_font.clone(), + minor_font: self.theme.minor_font.clone(), + } + } + + fn named_text_styles(&self) -> Vec { + let mut styles = built_in_text_styles(&self.theme) + .into_iter() + .map(|(name, style)| NamedTextStyle { + name, + style, + built_in: true, + }) + .collect::>(); + styles.extend( + self.custom_text_styles + .iter() + .map(|(name, style)| NamedTextStyle { + name: name.clone(), + style: style.clone(), + built_in: false, + }), + ); + styles.sort_by_cached_key(|style| style.name.to_ascii_lowercase()); + styles + } + + fn resolve_named_text_style( + &self, + style_name: &str, + action: &str, + ) -> Result { + let normalized_style_name = style_name.trim().to_ascii_lowercase(); + if let Some(style) = self.custom_text_styles.get(&normalized_style_name) { + return Ok(style.clone()); + } + built_in_text_style(&self.theme, &normalized_style_name).ok_or_else(|| { + PresentationArtifactError::UnsupportedFeature { + action: action.to_string(), + message: format!("unknown text style `{style_name}`"), + } + }) + } + + fn find_element_mut( + &mut self, + element_id: &str, + action: &str, + ) -> Result<&mut PresentationElement, PresentationArtifactError> { + let element_id = normalize_element_lookup_id(element_id); + for slide in &mut self.slides { + if let Some(element) = slide + .elements + .iter_mut() + .find(|element| element.element_id() == element_id) + { + return Ok(element); + } + } + Err(PresentationArtifactError::UnsupportedFeature { + action: action.to_string(), + message: format!("unknown element id `{element_id}`"), + }) + } + + fn get_slide_mut( + &mut self, + slide_index: u32, + action: &str, + ) -> Result<&mut PresentationSlide, PresentationArtifactError> { + let index = slide_index as usize; + if index >= self.slides.len() { + return Err(index_out_of_range(action, index, self.slides.len())); + } + Ok(&mut self.slides[index]) + } + + fn remove_element( + &mut self, + element_id: &str, + action: &str, + ) -> Result<(), PresentationArtifactError> { + let element_id = normalize_element_lookup_id(element_id); + for slide in &mut self.slides { + if let Some(index) = slide + .elements + .iter() + .position(|element| element.element_id() == element_id) + { + slide.elements.remove(index); + resequence_z_order(slide); + return Ok(()); + } + } + Err(PresentationArtifactError::UnsupportedFeature { + action: action.to_string(), + message: format!("unknown element id `{element_id}`"), + }) + } + + fn set_z_order( + &mut self, + element_id: &str, + target_index: usize, + action: &str, + ) -> Result<(), PresentationArtifactError> { + let element_id = normalize_element_lookup_id(element_id); + for slide in &mut self.slides { + if let Some(current_index) = slide + .elements + .iter() + .position(|element| element.element_id() == element_id) + { + let destination = target_index.min(slide.elements.len().saturating_sub(1)); + let element = slide.elements.remove(current_index); + slide.elements.insert(destination, element); + resequence_z_order(slide); + return Ok(()); + } + } + Err(PresentationArtifactError::UnsupportedFeature { + action: action.to_string(), + message: format!("unknown element id `{element_id}`"), + }) + } + + fn to_ppt_rs(&self) -> Presentation { + let mut presentation = self + .name + .as_deref() + .map(Presentation::with_title) + .unwrap_or_default(); + for slide in &self.slides { + presentation = presentation.add_slide(slide.to_ppt_rs(self.slide_size)); + } + presentation + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PresentationSlide { + slide_id: String, + notes: NotesState, + background_fill: Option, + layout_id: Option, + elements: Vec, +} + +struct ImportedPicture { + relationship_id: String, + frame: Rect, + crop: Option, + alt_text: Option, + rotation_degrees: Option, + flip_horizontal: bool, + flip_vertical: bool, + lock_aspect_ratio: bool, +} + +fn import_pptx_images( + path: &Path, + document: &mut PresentationDocument, + action: &str, +) -> Result<(), PresentationArtifactError> { + let file = + std::fs::File::open(path).map_err(|error| PresentationArtifactError::ImportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + let mut archive = + ZipArchive::new(file).map_err(|error| PresentationArtifactError::ImportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + for slide_index in 0..document.slides.len() { + let slide_number = slide_index + 1; + let slide_xml_path = format!("ppt/slides/slide{slide_number}.xml"); + let Some(slide_xml) = + zip_entry_string_if_exists(&mut archive, &slide_xml_path).map_err(|message| { + PresentationArtifactError::ImportFailed { + path: path.to_path_buf(), + message, + } + })? + else { + continue; + }; + let pictures = parse_imported_pictures(&slide_xml); + if pictures.is_empty() { + continue; + } + let relationships = zip_entry_string_if_exists( + &mut archive, + &format!("ppt/slides/_rels/slide{slide_number}.xml.rels"), + ) + .map_err(|message| PresentationArtifactError::ImportFailed { + path: path.to_path_buf(), + message, + })? + .map(|xml| parse_slide_image_relationship_targets(&xml)) + .unwrap_or_default(); + let mut imported_images = Vec::new(); + for picture in pictures { + let Some(target) = relationships.get(&picture.relationship_id) else { + continue; + }; + let media_path = resolve_zip_relative_path(&slide_xml_path, target); + let Some(bytes) = + zip_entry_bytes_if_exists(&mut archive, &media_path).map_err(|message| { + PresentationArtifactError::ImportFailed { + path: path.to_path_buf(), + message, + } + })? + else { + continue; + }; + let Some(filename) = Path::new(&media_path) + .file_name() + .and_then(|name| name.to_str()) + .map(str::to_owned) + else { + continue; + }; + let Ok(payload) = build_image_payload(bytes, filename, action) else { + continue; + }; + imported_images.push(ImageElement { + element_id: document.next_element_id(), + frame: picture.frame, + payload: Some(payload), + fit_mode: ImageFitMode::Stretch, + crop: picture.crop, + rotation_degrees: picture.rotation_degrees, + flip_horizontal: picture.flip_horizontal, + flip_vertical: picture.flip_vertical, + lock_aspect_ratio: picture.lock_aspect_ratio, + alt_text: picture.alt_text, + prompt: None, + is_placeholder: false, + placeholder: None, + z_order: 0, + }); + } + let slide = &mut document.slides[slide_index]; + for mut image in imported_images { + image.z_order = slide.elements.len(); + slide.elements.push(PresentationElement::Image(image)); + } + } + Ok(()) +} + +fn zip_entry_string_if_exists( + archive: &mut ZipArchive, + path: &str, +) -> Result, String> { + let Some(bytes) = zip_entry_bytes_if_exists(archive, path)? else { + return Ok(None); + }; + String::from_utf8(bytes) + .map(Some) + .map_err(|error| format!("zip entry `{path}` is not valid UTF-8: {error}")) +} + +fn zip_entry_bytes_if_exists( + archive: &mut ZipArchive, + path: &str, +) -> Result>, String> { + match archive.by_name(path) { + Ok(mut entry) => { + let mut bytes = Vec::new(); + entry + .read_to_end(&mut bytes) + .map_err(|error| format!("failed to read zip entry `{path}`: {error}"))?; + Ok(Some(bytes)) + } + Err(zip::result::ZipError::FileNotFound) => Ok(None), + Err(error) => Err(format!("failed to open zip entry `{path}`: {error}")), + } +} + +fn parse_imported_pictures(slide_xml: &str) -> Vec { + let mut pictures = Vec::new(); + let mut remaining = slide_xml; + while let Some(start) = remaining.find("") { + let block_start = start; + let Some(block_end_offset) = remaining[block_start..].find("") else { + break; + }; + let block_end = block_start + block_end_offset + "".len(); + let block = &remaining[block_start..block_end]; + remaining = &remaining[block_end..]; + + let Some(relationship_id) = xml_tag_attribute(block, "().unwrap_or(0.0) / 100_000.0, + xml_tag_attribute(block, "().ok()) + .unwrap_or(0.0) + / 100_000.0, + xml_tag_attribute(block, "().ok()) + .unwrap_or(0.0) + / 100_000.0, + xml_tag_attribute(block, "().ok()) + .unwrap_or(0.0) + / 100_000.0, + ) + }), + alt_text: xml_tag_attribute(block, "().ok()) + .map(|rotation| (rotation as f64 / 60_000.0).round() as i32), + flip_horizontal: xml_tag_attribute(block, " HashMap { + let mut relationships = HashMap::new(); + let mut remaining = rels_xml; + while let Some(start) = remaining.find("") else { + break; + }; + let tag_end = tag_start + tag_end_offset + 2; + let tag = &remaining[tag_start..tag_end]; + remaining = &remaining[tag_end..]; + if xml_attribute(tag, "Type").as_deref() + != Some("http://schemas.openxmlformats.org/officeDocument/2006/relationships/image") + { + continue; + } + let (Some(id), Some(target)) = (xml_attribute(tag, "Id"), xml_attribute(tag, "Target")) + else { + continue; + }; + relationships.insert(id, target); + } + relationships +} + +fn resolve_zip_relative_path(base_path: &str, target: &str) -> String { + let mut components = Path::new(base_path) + .parent() + .into_iter() + .flat_map(Path::components) + .filter_map(|component| match component { + std::path::Component::Normal(value) => Some(value.to_string_lossy().to_string()), + std::path::Component::CurDir => None, + std::path::Component::ParentDir => None, + std::path::Component::RootDir | std::path::Component::Prefix(_) => None, + }) + .collect::>(); + for component in Path::new(target).components() { + match component { + std::path::Component::Normal(value) => { + components.push(value.to_string_lossy().to_string()) + } + std::path::Component::ParentDir => { + components.pop(); + } + std::path::Component::CurDir => {} + std::path::Component::RootDir | std::path::Component::Prefix(_) => { + components.clear(); + } + } + } + components.join("/") +} + +fn xml_tag_attribute(xml: &str, tag_start: &str, attribute: &str) -> Option { + let start = xml.find(tag_start)?; + let tag = &xml[start..start + xml[start..].find('>')?]; + xml_attribute(tag, attribute) +} + +fn xml_attribute(tag: &str, attribute: &str) -> Option { + let pattern = format!(r#"{attribute}=""#); + let start = tag.find(&pattern)? + pattern.len(); + let end = start + tag[start..].find('"')?; + Some( + tag[start..end] + .replace(""", "\"") + .replace("'", "'") + .replace("<", "<") + .replace(">", ">") + .replace("&", "&"), + ) +} + +impl PresentationSlide { + fn to_ppt_rs(&self, slide_size: Rect) -> SlideContent { + let mut content = SlideContent::new("").layout(SlideLayout::Blank); + if self.notes.visible && !self.notes.text.is_empty() { + content = content.notes(&self.notes.text); + } + + if let Some(background_fill) = &self.background_fill { + content = content.add_shape( + Shape::new( + ShapeType::Rectangle, + 0, + 0, + points_to_emu(slide_size.width), + points_to_emu(slide_size.height), + ) + .with_fill(ShapeFill::new(background_fill)), + ); + } + + let mut ordered = self.elements.clone(); + ordered.sort_by_key(PresentationElement::z_order); + let mut hyperlink_seq = 1_u32; + for element in ordered { + match element { + PresentationElement::Text(text) => { + let mut shape = Shape::new( + ShapeType::Rectangle, + points_to_emu(text.frame.left), + points_to_emu(text.frame.top), + points_to_emu(text.frame.width), + points_to_emu(text.frame.height), + ) + .with_text(&text.text); + if let Some(fill) = text.fill { + shape = shape.with_fill(ShapeFill::new(&fill)); + } + if let Some(hyperlink) = &text.hyperlink { + let relationship_id = format!("rIdHyperlink{hyperlink_seq}"); + hyperlink_seq += 1; + shape = shape.with_hyperlink(hyperlink.to_ppt_rs(&relationship_id)); + } + content = content.add_shape(shape); + } + PresentationElement::Shape(shape) => { + let mut ppt_shape = Shape::new( + shape.geometry.to_ppt_rs(), + points_to_emu(shape.frame.left), + points_to_emu(shape.frame.top), + points_to_emu(shape.frame.width), + points_to_emu(shape.frame.height), + ); + if let Some(text) = shape.text { + ppt_shape = ppt_shape.with_text(&text); + } + if let Some(fill) = shape.fill { + ppt_shape = ppt_shape.with_fill(ShapeFill::new(&fill)); + } + if let Some(stroke) = shape.stroke { + ppt_shape = ppt_shape + .with_line(ShapeLine::new(&stroke.color, points_to_emu(stroke.width))); + } + if let Some(rotation) = shape.rotation_degrees { + ppt_shape = ppt_shape.with_rotation(rotation); + } + if let Some(hyperlink) = &shape.hyperlink { + let relationship_id = format!("rIdHyperlink{hyperlink_seq}"); + hyperlink_seq += 1; + ppt_shape = ppt_shape.with_hyperlink(hyperlink.to_ppt_rs(&relationship_id)); + } + content = content.add_shape(ppt_shape); + } + PresentationElement::Connector(connector) => { + let mut ppt_connector = Connector::new( + connector.connector_type.to_ppt_rs(), + points_to_emu(connector.start.left), + points_to_emu(connector.start.top), + points_to_emu(connector.end.left), + points_to_emu(connector.end.top), + ) + .with_line( + ConnectorLine::new( + &connector.line.color, + points_to_emu(connector.line.width), + ) + .with_dash(connector.line_style.to_ppt_rs()), + ) + .with_arrow_size(connector.arrow_size.to_ppt_rs()) + .with_start_arrow(connector.start_arrow.to_ppt_rs()) + .with_end_arrow(connector.end_arrow.to_ppt_rs()); + if let Some(label) = connector.label { + ppt_connector = ppt_connector.with_label(&label); + } + content = content.add_connector(ppt_connector); + } + PresentationElement::Image(image) => { + if let Some(ref payload) = image.payload { + let mut ppt_image = Image::from_bytes( + payload.bytes.clone(), + points_to_emu(image.frame.width), + points_to_emu(image.frame.height), + &payload.format, + ) + .position( + points_to_emu(image.frame.left), + points_to_emu(image.frame.top), + ); + if image.fit_mode != ImageFitMode::Stretch { + let (x, y, width, height, crop) = fit_image(&image); + ppt_image = Image::from_bytes( + payload.bytes.clone(), + points_to_emu(width), + points_to_emu(height), + &payload.format, + ) + .position(points_to_emu(x), points_to_emu(y)); + if let Some((left, top, right, bottom)) = crop { + ppt_image = ppt_image.with_crop(left, top, right, bottom); + } + } + if let Some((left, top, right, bottom)) = image.crop { + ppt_image = ppt_image.with_crop(left, top, right, bottom); + } + content = content.add_image(ppt_image); + } else { + let mut placeholder = Shape::new( + ShapeType::Rectangle, + points_to_emu(image.frame.left), + points_to_emu(image.frame.top), + points_to_emu(image.frame.width), + points_to_emu(image.frame.height), + ) + .with_text(image.prompt.as_deref().unwrap_or("Image placeholder")); + if let Some(rotation) = image.rotation_degrees { + placeholder = placeholder.with_rotation(rotation); + } + content = content.add_shape(placeholder); + } + } + PresentationElement::Table(table) => { + let mut builder = TableBuilder::new( + table + .column_widths + .iter() + .copied() + .map(points_to_emu) + .collect(), + ) + .position( + points_to_emu(table.frame.left), + points_to_emu(table.frame.top), + ); + for (row_index, row) in table.rows.into_iter().enumerate() { + let cells = row + .into_iter() + .enumerate() + .map(|(column_index, cell)| { + build_table_cell(cell, &table.merges, row_index, column_index) + }) + .collect::>(); + let mut table_row = TableRow::new(cells); + if let Some(height) = table.row_heights.get(row_index) { + table_row = table_row.with_height(points_to_emu(*height)); + } + builder = builder.add_row(table_row); + } + content = content.table(builder.build()); + } + PresentationElement::Chart(chart) => { + let mut ppt_chart = Chart::new( + chart.title.as_deref().unwrap_or("Chart"), + chart.chart_type.to_ppt_rs(), + chart.categories, + points_to_emu(chart.frame.left), + points_to_emu(chart.frame.top), + points_to_emu(chart.frame.width), + points_to_emu(chart.frame.height), + ); + for series in chart.series { + ppt_chart = + ppt_chart.add_series(ChartSeries::new(&series.name, series.values)); + } + content = content.add_chart(ppt_chart); + } + } + } + content + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +enum PresentationElement { + Text(TextElement), + Shape(ShapeElement), + Connector(ConnectorElement), + Image(ImageElement), + Table(TableElement), + Chart(ChartElement), +} + +impl PresentationElement { + fn element_id(&self) -> &str { + match self { + Self::Text(element) => &element.element_id, + Self::Shape(element) => &element.element_id, + Self::Connector(element) => &element.element_id, + Self::Image(element) => &element.element_id, + Self::Table(element) => &element.element_id, + Self::Chart(element) => &element.element_id, + } + } + + fn set_element_id(&mut self, new_id: String) { + match self { + Self::Text(element) => element.element_id = new_id, + Self::Shape(element) => element.element_id = new_id, + Self::Connector(element) => element.element_id = new_id, + Self::Image(element) => element.element_id = new_id, + Self::Table(element) => element.element_id = new_id, + Self::Chart(element) => element.element_id = new_id, + } + } + + fn kind(&self) -> &'static str { + match self { + Self::Text(_) => "text", + Self::Shape(_) => "shape", + Self::Connector(_) => "connector", + Self::Image(_) => "image", + Self::Table(_) => "table", + Self::Chart(_) => "chart", + } + } + + fn z_order(&self) -> usize { + match self { + Self::Text(element) => element.z_order, + Self::Shape(element) => element.z_order, + Self::Connector(element) => element.z_order, + Self::Image(element) => element.z_order, + Self::Table(element) => element.z_order, + Self::Chart(element) => element.z_order, + } + } + + fn set_z_order(&mut self, z_order: usize) { + match self { + Self::Text(element) => element.z_order = z_order, + Self::Shape(element) => element.z_order = z_order, + Self::Connector(element) => element.z_order = z_order, + Self::Image(element) => element.z_order = z_order, + Self::Table(element) => element.z_order = z_order, + Self::Chart(element) => element.z_order = z_order, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct TextElement { + element_id: String, + text: String, + frame: Rect, + fill: Option, + style: TextStyle, + hyperlink: Option, + #[serde(default)] + rich_text: RichTextState, + placeholder: Option, + z_order: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ShapeElement { + element_id: String, + geometry: ShapeGeometry, + frame: Rect, + fill: Option, + stroke: Option, + text: Option, + text_style: TextStyle, + hyperlink: Option, + rich_text: Option, + placeholder: Option, + rotation_degrees: Option, + flip_horizontal: bool, + flip_vertical: bool, + z_order: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ConnectorElement { + element_id: String, + connector_type: ConnectorKind, + start: PointArgs, + end: PointArgs, + line: StrokeStyle, + line_style: LineStyle, + start_arrow: ConnectorArrowKind, + end_arrow: ConnectorArrowKind, + arrow_size: ConnectorArrowScale, + label: Option, + z_order: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct ImageElement { + pub(crate) element_id: String, + pub(crate) frame: Rect, + pub(crate) payload: Option, + pub(crate) fit_mode: ImageFitMode, + pub(crate) crop: Option, + pub(crate) rotation_degrees: Option, + pub(crate) flip_horizontal: bool, + pub(crate) flip_vertical: bool, + pub(crate) lock_aspect_ratio: bool, + pub(crate) alt_text: Option, + pub(crate) prompt: Option, + pub(crate) is_placeholder: bool, + pub(crate) placeholder: Option, + pub(crate) z_order: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct TableElement { + element_id: String, + frame: Rect, + rows: Vec>, + column_widths: Vec, + row_heights: Vec, + style: Option, + style_options: TableStyleOptions, + borders: Option, + right_to_left: bool, + merges: Vec, + z_order: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ChartElement { + element_id: String, + frame: Rect, + chart_type: ChartTypeSpec, + categories: Vec, + series: Vec, + title: Option, + style_index: Option, + has_legend: bool, + legend: Option, + x_axis: Option, + y_axis: Option, + data_labels: Option, + chart_fill: Option, + plot_area_fill: Option, + z_order: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct ImagePayload { + pub(crate) bytes: Vec, + pub(crate) format: String, + pub(crate) width_px: u32, + pub(crate) height_px: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ChartSeriesSpec { + name: String, + values: Vec, + categories: Option>, + x_values: Option>, + fill: Option, + stroke: Option, + marker: Option, + #[serde(default)] + data_label_overrides: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +enum ShapeGeometry { + Rectangle, + RoundedRectangle, + Ellipse, + Triangle, + RightTriangle, + Diamond, + Pentagon, + Hexagon, + Octagon, + Star4, + Star5, + Star6, + Star8, + RightArrow, + LeftArrow, + UpArrow, + DownArrow, + LeftRightArrow, + UpDownArrow, + Chevron, + Heart, + Cloud, + Wave, + FlowChartProcess, + FlowChartDecision, + FlowChartConnector, + Parallelogram, + Trapezoid, +} + +impl ShapeGeometry { + fn from_shape_type(shape_type: ShapeType) -> Self { + match shape_type { + ShapeType::RoundedRectangle => Self::RoundedRectangle, + ShapeType::Ellipse | ShapeType::Circle => Self::Ellipse, + ShapeType::Triangle => Self::Triangle, + ShapeType::RightTriangle => Self::RightTriangle, + ShapeType::Diamond => Self::Diamond, + ShapeType::Pentagon => Self::Pentagon, + ShapeType::Hexagon => Self::Hexagon, + ShapeType::Octagon => Self::Octagon, + ShapeType::Star4 => Self::Star4, + ShapeType::Star5 => Self::Star5, + ShapeType::Star6 => Self::Star6, + ShapeType::Star8 => Self::Star8, + ShapeType::RightArrow => Self::RightArrow, + ShapeType::LeftArrow => Self::LeftArrow, + ShapeType::UpArrow => Self::UpArrow, + ShapeType::DownArrow => Self::DownArrow, + ShapeType::LeftRightArrow => Self::LeftRightArrow, + ShapeType::UpDownArrow => Self::UpDownArrow, + ShapeType::ChevronArrow => Self::Chevron, + ShapeType::Heart => Self::Heart, + ShapeType::Cloud => Self::Cloud, + ShapeType::Wave => Self::Wave, + ShapeType::FlowChartProcess => Self::FlowChartProcess, + ShapeType::FlowChartDecision => Self::FlowChartDecision, + ShapeType::FlowChartConnector => Self::FlowChartConnector, + ShapeType::Parallelogram => Self::Parallelogram, + ShapeType::Trapezoid => Self::Trapezoid, + _ => Self::Rectangle, + } + } + + fn to_ppt_rs(self) -> ShapeType { + match self { + Self::Rectangle => ShapeType::Rectangle, + Self::RoundedRectangle => ShapeType::RoundedRectangle, + Self::Ellipse => ShapeType::Ellipse, + Self::Triangle => ShapeType::Triangle, + Self::RightTriangle => ShapeType::RightTriangle, + Self::Diamond => ShapeType::Diamond, + Self::Pentagon => ShapeType::Pentagon, + Self::Hexagon => ShapeType::Hexagon, + Self::Octagon => ShapeType::Octagon, + Self::Star4 => ShapeType::Star4, + Self::Star5 => ShapeType::Star5, + Self::Star6 => ShapeType::Star6, + Self::Star8 => ShapeType::Star8, + Self::RightArrow => ShapeType::RightArrow, + Self::LeftArrow => ShapeType::LeftArrow, + Self::UpArrow => ShapeType::UpArrow, + Self::DownArrow => ShapeType::DownArrow, + Self::LeftRightArrow => ShapeType::LeftRightArrow, + Self::UpDownArrow => ShapeType::UpDownArrow, + Self::Chevron => ShapeType::ChevronArrow, + Self::Heart => ShapeType::Heart, + Self::Cloud => ShapeType::Cloud, + Self::Wave => ShapeType::Wave, + Self::FlowChartProcess => ShapeType::FlowChartProcess, + Self::FlowChartDecision => ShapeType::FlowChartDecision, + Self::FlowChartConnector => ShapeType::FlowChartConnector, + Self::Parallelogram => ShapeType::Parallelogram, + Self::Trapezoid => ShapeType::Trapezoid, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +enum ChartTypeSpec { + Bar, + BarHorizontal, + BarStacked, + BarStacked100, + Line, + LineMarkers, + LineStacked, + Pie, + Doughnut, + Area, + AreaStacked, + AreaStacked100, + Scatter, + ScatterLines, + ScatterSmooth, + Bubble, + Radar, + RadarFilled, + StockHlc, + StockOhlc, + Combo, +} + +impl ChartTypeSpec { + fn to_ppt_rs(self) -> ChartType { + match self { + Self::Bar => ChartType::Bar, + Self::BarHorizontal => ChartType::BarHorizontal, + Self::BarStacked => ChartType::BarStacked, + Self::BarStacked100 => ChartType::BarStacked100, + Self::Line => ChartType::Line, + Self::LineMarkers => ChartType::LineMarkers, + Self::LineStacked => ChartType::LineStacked, + Self::Pie => ChartType::Pie, + Self::Doughnut => ChartType::Doughnut, + Self::Area => ChartType::Area, + Self::AreaStacked => ChartType::AreaStacked, + Self::AreaStacked100 => ChartType::AreaStacked100, + Self::Scatter => ChartType::Scatter, + Self::ScatterLines => ChartType::ScatterLines, + Self::ScatterSmooth => ChartType::ScatterSmooth, + Self::Bubble => ChartType::Bubble, + Self::Radar => ChartType::Radar, + Self::RadarFilled => ChartType::RadarFilled, + Self::StockHlc => ChartType::StockHLC, + Self::StockOhlc => ChartType::StockOHLC, + Self::Combo => ChartType::Combo, + } + } +} + +impl ConnectorKind { + fn to_ppt_rs(self) -> ConnectorType { + match self { + Self::Straight => ConnectorType::Straight, + Self::Elbow => ConnectorType::Elbow, + Self::Curved => ConnectorType::Curved, + } + } +} + +impl ConnectorArrowKind { + fn to_ppt_rs(self) -> ArrowType { + match self { + Self::None => ArrowType::None, + Self::Triangle => ArrowType::Triangle, + Self::Stealth => ArrowType::Stealth, + Self::Diamond => ArrowType::Diamond, + Self::Oval => ArrowType::Oval, + Self::Open => ArrowType::Open, + } + } +} + +impl ConnectorArrowScale { + fn to_ppt_rs(self) -> ArrowSize { + match self { + Self::Small => ArrowSize::Small, + Self::Medium => ArrowSize::Medium, + Self::Large => ArrowSize::Large, + } + } +} + +impl LineStyle { + fn to_ppt_rs(self) -> LineDash { + match self { + Self::Solid => LineDash::Solid, + Self::Dashed => LineDash::Dash, + Self::Dotted => LineDash::Dot, + Self::DashDot => LineDash::DashDot, + Self::DashDotDot => LineDash::DashDotDot, + Self::LongDash => LineDash::LongDash, + Self::LongDashDot => LineDash::LongDashDot, + } + } + + fn to_ppt_xml(self) -> &'static str { + match self { + Self::Solid => "solid", + Self::Dashed => "dash", + Self::Dotted => "dot", + Self::DashDot => "dashDot", + Self::DashDotDot => "dashDotDot", + Self::LongDash => "lgDash", + Self::LongDashDot => "lgDashDot", + } + } + + fn as_api_str(self) -> &'static str { + match self { + Self::Solid => "solid", + Self::Dashed => "dashed", + Self::Dotted => "dotted", + Self::DashDot => "dash-dot", + Self::DashDotDot => "dash-dot-dot", + Self::LongDash => "long-dash", + Self::LongDashDot => "long-dash-dot", + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub(crate) enum ImageFitMode { + Stretch, + Contain, + Cover, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct StrokeStyle { + color: String, + width: u32, + style: LineStyle, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +enum ConnectorKind { + Straight, + Elbow, + Curved, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +enum ConnectorArrowKind { + None, + Triangle, + Stealth, + Diamond, + Oval, + Open, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +enum ConnectorArrowScale { + Small, + Medium, + Large, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +enum LineStyle { + Solid, + Dashed, + Dotted, + DashDot, + DashDotDot, + LongDash, + LongDashDot, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub(crate) struct Rect { + pub(crate) left: u32, + pub(crate) top: u32, + pub(crate) width: u32, + pub(crate) height: u32, +} + +impl Rect { + fn from_emu(left: u32, top: u32, width: u32, height: u32) -> Self { + Self { + left: emu_to_points(left), + top: emu_to_points(top), + width: emu_to_points(width), + height: emu_to_points(height), + } + } +} + +impl From for Rect { + fn from(value: PositionArgs) -> Self { + Self { + left: value.left, + top: value.top, + width: value.width, + height: value.height, + } + } +} + +fn apply_partial_position(rect: Rect, position: PartialPositionArgs) -> Rect { + Rect { + left: position.left.unwrap_or(rect.left), + top: position.top.unwrap_or(rect.top), + width: position.width.unwrap_or(rect.width), + height: position.height.unwrap_or(rect.height), + } +} + +fn apply_partial_position_to_image(image: &ImageElement, position: PartialPositionArgs) -> Rect { + let mut frame = apply_partial_position(image.frame, position.clone()); + if image.lock_aspect_ratio { + let base_ratio = image + .payload + .as_ref() + .map(|payload| payload.width_px as f64 / payload.height_px as f64) + .unwrap_or_else(|| image.frame.width as f64 / image.frame.height as f64); + if let Some(width) = position.width + && position.height.is_none() + { + frame.height = (width as f64 / base_ratio).round() as u32; + } else if let Some(height) = position.height + && position.width.is_none() + { + frame.width = (height as f64 * base_ratio).round() as u32; + } + } + frame +} diff --git a/codex-rs/artifact-presentation/src/presentation_artifact/parsing.rs b/codex-rs/artifact-presentation/src/presentation_artifact/parsing.rs new file mode 100644 index 0000000000..efca31a99b --- /dev/null +++ b/codex-rs/artifact-presentation/src/presentation_artifact/parsing.rs @@ -0,0 +1,1273 @@ +fn parse_args(action: &str, value: &Value) -> Result +where + T: for<'de> Deserialize<'de>, +{ + serde_json::from_value(value.clone()).map_err(|error| PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: error.to_string(), + }) +} + +fn required_artifact_id( + request: &PresentationArtifactRequest, +) -> Result { + request + .artifact_id + .clone() + .ok_or_else(|| PresentationArtifactError::MissingArtifactId { + action: request.action.clone(), + }) +} + +fn is_read_only_action(action: &str) -> bool { + matches!( + action, + "get_summary" + | "list_slides" + | "list_layouts" + | "list_layout_placeholders" + | "list_slide_placeholders" + | "inspect" + | "resolve" + | "to_proto" + | "get_style" + | "describe_styles" + | "record_patch" + ) +} + +fn tracks_history(action: &str) -> bool { + !is_read_only_action(action) + && !matches!( + action, + "export_pptx" | "export_preview" | "undo" | "redo" | "apply_patch" + ) +} + +fn patch_operation_supported(action: &str) -> bool { + tracks_history(action) && !matches!(action, "create" | "import_pptx" | "delete_artifact") +} + +fn resolve_path(cwd: &Path, path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + cwd.join(path) + } +} + +fn normalize_color( + color: &str, + action: &str, + field: &str, +) -> Result { + normalize_color_with_palette(None, color, action, field) +} + +fn normalize_color_with_document( + document: &PresentationDocument, + color: &str, + action: &str, + field: &str, +) -> Result { + normalize_color_with_palette(Some(&document.theme), color, action, field) +} + +fn normalize_color_with_palette( + theme: Option<&ThemeState>, + color: &str, + action: &str, + field: &str, +) -> Result { + let trimmed = color.trim(); + let normalized = theme + .and_then(|palette| palette.resolve_color(trimmed)) + .unwrap_or_else(|| trimmed.trim_start_matches('#').to_uppercase()); + if normalized.len() != 6 + || !normalized + .chars() + .all(|character| character.is_ascii_hexdigit()) + { + return Err(PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("field `{field}` must be a 6-digit RGB hex color"), + }); + } + Ok(normalized) +} + +fn parse_shape_geometry( + geometry: &str, + action: &str, +) -> Result { + match geometry { + "rectangle" | "rect" => Ok(ShapeGeometry::Rectangle), + "rounded_rectangle" | "roundedRect" => Ok(ShapeGeometry::RoundedRectangle), + "ellipse" | "circle" => Ok(ShapeGeometry::Ellipse), + "triangle" => Ok(ShapeGeometry::Triangle), + "right_triangle" => Ok(ShapeGeometry::RightTriangle), + "diamond" => Ok(ShapeGeometry::Diamond), + "pentagon" => Ok(ShapeGeometry::Pentagon), + "hexagon" => Ok(ShapeGeometry::Hexagon), + "octagon" => Ok(ShapeGeometry::Octagon), + "star4" => Ok(ShapeGeometry::Star4), + "star" | "star5" => Ok(ShapeGeometry::Star5), + "star6" => Ok(ShapeGeometry::Star6), + "star8" => Ok(ShapeGeometry::Star8), + "right_arrow" => Ok(ShapeGeometry::RightArrow), + "left_arrow" => Ok(ShapeGeometry::LeftArrow), + "up_arrow" => Ok(ShapeGeometry::UpArrow), + "down_arrow" => Ok(ShapeGeometry::DownArrow), + "left_right_arrow" | "leftRightArrow" => Ok(ShapeGeometry::LeftRightArrow), + "up_down_arrow" | "upDownArrow" => Ok(ShapeGeometry::UpDownArrow), + "chevron" => Ok(ShapeGeometry::Chevron), + "heart" => Ok(ShapeGeometry::Heart), + "cloud" => Ok(ShapeGeometry::Cloud), + "wave" => Ok(ShapeGeometry::Wave), + "flowChartProcess" | "flow_chart_process" => Ok(ShapeGeometry::FlowChartProcess), + "flowChartDecision" | "flow_chart_decision" => Ok(ShapeGeometry::FlowChartDecision), + "flowChartConnector" | "flow_chart_connector" => Ok(ShapeGeometry::FlowChartConnector), + "parallelogram" => Ok(ShapeGeometry::Parallelogram), + "trapezoid" => Ok(ShapeGeometry::Trapezoid), + _ => Err(PresentationArtifactError::UnsupportedFeature { + action: action.to_string(), + message: format!("geometry `{geometry}` is not supported"), + }), + } +} + +fn parse_chart_type( + chart_type: &str, + action: &str, +) -> Result { + match chart_type { + "bar" => Ok(ChartTypeSpec::Bar), + "bar_horizontal" => Ok(ChartTypeSpec::BarHorizontal), + "bar_stacked" => Ok(ChartTypeSpec::BarStacked), + "bar_stacked_100" => Ok(ChartTypeSpec::BarStacked100), + "line" => Ok(ChartTypeSpec::Line), + "line_markers" => Ok(ChartTypeSpec::LineMarkers), + "line_stacked" => Ok(ChartTypeSpec::LineStacked), + "pie" => Ok(ChartTypeSpec::Pie), + "doughnut" => Ok(ChartTypeSpec::Doughnut), + "area" => Ok(ChartTypeSpec::Area), + "area_stacked" => Ok(ChartTypeSpec::AreaStacked), + "area_stacked_100" => Ok(ChartTypeSpec::AreaStacked100), + "scatter" => Ok(ChartTypeSpec::Scatter), + "scatter_lines" => Ok(ChartTypeSpec::ScatterLines), + "scatter_smooth" => Ok(ChartTypeSpec::ScatterSmooth), + "bubble" => Ok(ChartTypeSpec::Bubble), + "radar" => Ok(ChartTypeSpec::Radar), + "radar_filled" => Ok(ChartTypeSpec::RadarFilled), + "stock_hlc" => Ok(ChartTypeSpec::StockHlc), + "stock_ohlc" => Ok(ChartTypeSpec::StockOhlc), + "combo" => Ok(ChartTypeSpec::Combo), + _ => Err(PresentationArtifactError::UnsupportedFeature { + action: action.to_string(), + message: format!("chart_type `{chart_type}` is not supported"), + }), + } +} + +fn parse_chart_marker(marker: Option) -> ChartMarkerStyle { + marker + .map(|marker| ChartMarkerStyle { + symbol: marker.symbol, + size: marker.size, + }) + .unwrap_or_default() +} + +fn parse_chart_data_labels( + document: &PresentationDocument, + data_labels: Option, + action: &str, +) -> Result, PresentationArtifactError> { + data_labels + .map(|data_labels| { + Ok(ChartDataLabels { + show_value: data_labels.show_value.unwrap_or(false), + show_category_name: data_labels.show_category_name.unwrap_or(false), + show_leader_lines: data_labels.show_leader_lines.unwrap_or(false), + position: data_labels.position, + text_style: normalize_text_style_with_document( + document, + &data_labels.text_style, + action, + )?, + }) + }) + .transpose() +} + +fn parse_chart_series( + document: &PresentationDocument, + series: Vec, + action: &str, +) -> Result, PresentationArtifactError> { + series + .into_iter() + .map(|entry| { + if entry.values.is_empty() { + return Err(PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("series `{}` must contain at least one value", entry.name), + }); + } + let fill = entry + .fill + .as_deref() + .map(|value| normalize_color_with_document(document, value, action, "series.fill")) + .transpose()?; + let stroke = entry + .stroke + .map(|stroke| { + Ok(StrokeStyle { + color: normalize_color_with_document( + document, + &stroke.color, + action, + "series.stroke.color", + )?, + width: stroke.width, + style: stroke + .style + .as_deref() + .map(|value| parse_line_style(value, action)) + .transpose()? + .unwrap_or(LineStyle::Solid), + }) + }) + .transpose()?; + let data_label_overrides = entry + .data_label_overrides + .unwrap_or_default() + .into_iter() + .map(|override_args| { + Ok(ChartDataLabelOverride { + idx: override_args.idx as usize, + text: override_args.text, + position: override_args.position, + text_style: normalize_text_style_with_document( + document, + &override_args.text_style, + action, + )?, + fill: override_args + .fill + .as_deref() + .map(|value| { + normalize_color_with_document(document, value, action, "fill") + }) + .transpose()?, + stroke: override_args + .stroke + .map(|stroke| { + Ok(StrokeStyle { + color: normalize_color_with_document( + document, + &stroke.color, + action, + "stroke.color", + )?, + width: stroke.width, + style: stroke + .style + .as_deref() + .map(|value| parse_line_style(value, action)) + .transpose()? + .unwrap_or(LineStyle::Solid), + }) + }) + .transpose()?, + }) + }) + .collect::, _>>()?; + Ok(ChartSeriesSpec { + name: entry.name, + values: entry.values, + categories: entry.categories, + x_values: entry.x_values, + fill, + stroke, + marker: Some(parse_chart_marker(entry.marker)), + data_label_overrides, + }) + }) + .collect() +} + +fn parse_stroke( + document: &PresentationDocument, + stroke: Option, + action: &str, +) -> Result, PresentationArtifactError> { + stroke + .map(|value| parse_required_stroke(document, value, action)) + .transpose() +} + +fn parse_required_stroke( + document: &PresentationDocument, + stroke: StrokeArgs, + action: &str, +) -> Result { + Ok(StrokeStyle { + color: normalize_color_with_document(document, &stroke.color, action, "stroke.color")?, + width: stroke.width, + style: stroke + .style + .as_deref() + .map(|style| parse_line_style(style, action)) + .transpose()? + .unwrap_or(LineStyle::Solid), + }) +} + +fn parse_connector_kind( + connector_type: &str, + action: &str, +) -> Result { + match connector_type { + "straight" => Ok(ConnectorKind::Straight), + "elbow" => Ok(ConnectorKind::Elbow), + "curved" => Ok(ConnectorKind::Curved), + _ => Err(PresentationArtifactError::UnsupportedFeature { + action: action.to_string(), + message: format!("connector_type `{connector_type}` is not supported"), + }), + } +} + +fn parse_connector_arrow( + value: &str, + action: &str, +) -> Result { + match value { + "none" => Ok(ConnectorArrowKind::None), + "triangle" => Ok(ConnectorArrowKind::Triangle), + "stealth" => Ok(ConnectorArrowKind::Stealth), + "diamond" => Ok(ConnectorArrowKind::Diamond), + "oval" => Ok(ConnectorArrowKind::Oval), + "open" => Ok(ConnectorArrowKind::Open), + _ => Err(PresentationArtifactError::UnsupportedFeature { + action: action.to_string(), + message: format!("connector arrow `{value}` is not supported"), + }), + } +} + +fn parse_connector_arrow_size( + value: &str, + action: &str, +) -> Result { + match value { + "small" => Ok(ConnectorArrowScale::Small), + "medium" => Ok(ConnectorArrowScale::Medium), + "large" => Ok(ConnectorArrowScale::Large), + _ => Err(PresentationArtifactError::UnsupportedFeature { + action: action.to_string(), + message: format!("connector arrow_size `{value}` is not supported"), + }), + } +} + +fn parse_line_style(value: &str, action: &str) -> Result { + match value { + "solid" => Ok(LineStyle::Solid), + "dashed" => Ok(LineStyle::Dashed), + "dotted" => Ok(LineStyle::Dotted), + "dash-dot" | "dash_dot" => Ok(LineStyle::DashDot), + "dash-dot-dot" | "dash_dot_dot" => Ok(LineStyle::DashDotDot), + "long-dash" | "long_dash" => Ok(LineStyle::LongDash), + "long-dash-dot" | "long_dash_dot" => Ok(LineStyle::LongDashDot), + _ => Err(PresentationArtifactError::UnsupportedFeature { + action: action.to_string(), + message: format!("line style `{value}` is not supported"), + }), + } +} + +fn parse_connector_line( + document: &PresentationDocument, + line: Option, + action: &str, +) -> Result { + let line = line.unwrap_or_default(); + Ok(ParsedConnectorLine { + color: line + .color + .as_deref() + .map(|value| normalize_color_with_document(document, value, action, "line.color")) + .transpose()? + .unwrap_or_else(|| "000000".to_string()), + width: line.width.unwrap_or(1), + style: line + .style + .as_deref() + .map(|value| parse_line_style(value, action)) + .transpose()? + .unwrap_or(LineStyle::Solid), + }) +} + +struct ParsedConnectorLine { + color: String, + width: u32, + style: LineStyle, +} + +fn normalize_text_style_with_document( + document: &PresentationDocument, + styling: &TextStylingArgs, + action: &str, +) -> Result { + normalize_text_style_with_palette(Some(&document.theme), styling, action, |style_name| { + document.resolve_named_text_style(style_name, action) + }) +} + +fn normalize_text_layout( + layout: &TextLayoutArgs, + action: &str, +) -> Result { + let wrap = layout + .wrap + .as_deref() + .map(|value| match value { + "square" => Ok(TextWrapMode::Square), + "none" => Ok(TextWrapMode::None), + _ => Err(PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("unsupported wrap `{value}`"), + }), + }) + .transpose()?; + let auto_fit = layout + .auto_fit + .as_deref() + .map(|value| match value { + "none" => Ok(TextAutoFitMode::None), + "shrinkText" | "shrink_text" => Ok(TextAutoFitMode::ShrinkText), + "resizeShapeToFitText" | "resize_shape_to_fit_text" => { + Ok(TextAutoFitMode::ResizeShapeToFitText) + } + _ => Err(PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("unsupported auto_fit `{value}`"), + }), + }) + .transpose()?; + let vertical_alignment = layout + .vertical_alignment + .as_deref() + .map(|value| match value { + "top" => Ok(TextVerticalAlignment::Top), + "middle" | "center" => Ok(TextVerticalAlignment::Middle), + "bottom" => Ok(TextVerticalAlignment::Bottom), + _ => Err(PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("unsupported vertical_alignment `{value}`"), + }), + }) + .transpose()?; + Ok(TextLayoutState { + insets: layout.insets.as_ref().map(|insets| TextInsets { + left: insets.left, + right: insets.right, + top: insets.top, + bottom: insets.bottom, + }), + wrap, + auto_fit, + vertical_alignment, + }) +} + +fn normalize_rich_text_input( + document: &PresentationDocument, + input: RichTextInput, + action: &str, +) -> Result<(String, RichTextState), PresentationArtifactError> { + let mut text = String::new(); + let mut ranges = Vec::new(); + let paragraphs = match input { + RichTextInput::Plain(value) => vec![RichParagraphInput::Plain(value)], + RichTextInput::Paragraphs(paragraphs) => paragraphs, + }; + for (paragraph_index, paragraph) in paragraphs.into_iter().enumerate() { + if paragraph_index > 0 { + text.push('\n'); + } + let paragraph_start = text.chars().count(); + match paragraph { + RichParagraphInput::Plain(value) => text.push_str(&value), + RichParagraphInput::Runs(runs) => { + for run in runs { + match run { + RichRunInput::Plain(value) => text.push_str(&value), + RichRunInput::Styled(value) => { + let start_cp = text.chars().count(); + text.push_str(&value.run); + let length = value.run.chars().count(); + let style = normalize_text_style_with_document( + document, + &value.text_style, + action, + )?; + let hyperlink = value + .link + .as_ref() + .map(|link| parse_rich_text_link(link, action)) + .transpose()?; + if length > 0 + && (!text_style_is_empty(&style) || hyperlink.is_some()) + { + ranges.push(TextRangeAnnotation { + range_id: format!("inline_{paragraph_index}_{start_cp}"), + start_cp, + length, + style, + hyperlink, + spacing_before: None, + spacing_after: None, + line_spacing: None, + }); + } + } + } + } + } + } + let paragraph_len = text.chars().count() - paragraph_start; + if paragraph_len == 0 { + continue; + } + } + Ok(( + text, + RichTextState { + ranges, + layout: TextLayoutState::default(), + }, + )) +} + +fn parse_rich_text_link( + link: &RichTextLinkInput, + action: &str, +) -> Result { + let uri = link + .uri + .as_ref() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: "`link.uri` is required".to_string(), + })?; + let target = if link.is_external.unwrap_or(true) { + HyperlinkTarget::Url(uri.clone()) + } else { + HyperlinkTarget::File(uri.clone()) + }; + Ok(HyperlinkState { + target, + tooltip: None, + highlight_click: true, + }) +} + +fn text_style_is_empty(style: &TextStyle) -> bool { + style.style_name.is_none() + && style.font_size.is_none() + && style.font_family.is_none() + && style.color.is_none() + && style.alignment.is_none() + && !style.bold + && !style.italic + && !style.underline +} + +fn resolve_text_range_selector( + text: &str, + query: Option<&str>, + occurrence: Option, + start_cp: Option, + length: Option, + action: &str, +) -> Result<(usize, usize, Option), PresentationArtifactError> { + if let Some(query) = query { + let occurrence = occurrence.unwrap_or(0); + let haystack = text.chars().collect::>(); + let needle = query.chars().collect::>(); + if needle.is_empty() { + return Err(PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: "`query` must not be empty".to_string(), + }); + } + let mut matches = Vec::new(); + for start in 0..=haystack.len().saturating_sub(needle.len()) { + if haystack[start..start + needle.len()] == needle[..] { + matches.push(start); + } + } + let Some(found) = matches.get(occurrence).copied() else { + return Err(PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("query `{query}` occurrence {occurrence} was not found"), + }); + }; + return Ok((found, needle.len(), Some(query.to_string()))); + } + let start_cp = start_cp.ok_or_else(|| PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: "provide either `query` or `start_cp`".to_string(), + })?; + let length = length.ok_or_else(|| PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: "`length` is required with `start_cp`".to_string(), + })?; + if start_cp + length > text.chars().count() { + return Err(PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: "text range is out of bounds".to_string(), + }); + } + Ok((start_cp, length, None)) +} + +fn normalize_text_style_with_palette( + theme: Option<&ThemeState>, + styling: &TextStylingArgs, + action: &str, + resolve_style_name: impl Fn(&str) -> Result, +) -> Result { + let mut style = styling + .style + .as_deref() + .map(resolve_style_name) + .transpose()? + .unwrap_or_default(); + style.font_size = styling.font_size.or(style.font_size); + style.font_family = styling.font_family.clone().or(style.font_family); + style.color = styling + .color + .as_deref() + .map(|value| normalize_color_with_palette(theme, value, action, "color")) + .transpose()? + .or(style.color); + style.alignment = styling + .alignment + .as_deref() + .map(|value| parse_alignment(value, action)) + .transpose()? + .or(style.alignment); + style.bold = styling.bold.unwrap_or(style.bold); + style.italic = styling.italic.unwrap_or(style.italic); + style.underline = styling.underline.unwrap_or(style.underline); + if let Some(style_name) = &styling.style { + style.style_name = Some(normalize_style_name(style_name, action)?); + } + Ok(style) +} + +fn parse_hyperlink_state( + document: &PresentationDocument, + args: &SetHyperlinkArgs, + action: &str, +) -> Result { + let link_type = + args.link_type + .as_deref() + .ok_or_else(|| PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: "`link_type` is required unless `clear` is true".to_string(), + })?; + let target = match link_type { + "url" => HyperlinkTarget::Url(required_hyperlink_field(&args.url, action, "url")?.clone()), + "slide" => { + let slide_index = + args.slide_index + .ok_or_else(|| PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: "`slide_index` is required for slide hyperlinks".to_string(), + })?; + if slide_index as usize >= document.slides.len() { + return Err(index_out_of_range( + action, + slide_index as usize, + document.slides.len(), + )); + } + HyperlinkTarget::Slide(slide_index) + } + "first_slide" => HyperlinkTarget::FirstSlide, + "last_slide" => HyperlinkTarget::LastSlide, + "next_slide" => HyperlinkTarget::NextSlide, + "previous_slide" => HyperlinkTarget::PreviousSlide, + "end_show" => HyperlinkTarget::EndShow, + "email" => HyperlinkTarget::Email { + address: required_hyperlink_field(&args.address, action, "address")?.clone(), + subject: args.subject.clone(), + }, + "file" => { + HyperlinkTarget::File(required_hyperlink_field(&args.path, action, "path")?.clone()) + } + other => { + return Err(PresentationArtifactError::UnsupportedFeature { + action: action.to_string(), + message: format!("hyperlink type `{other}` is not supported"), + }); + } + }; + Ok(HyperlinkState { + target, + tooltip: args.tooltip.clone(), + highlight_click: args.highlight_click.unwrap_or(true), + }) +} + +fn required_hyperlink_field<'a>( + value: &'a Option, + action: &str, + field: &str, +) -> Result<&'a String, PresentationArtifactError> { + value + .as_ref() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("`{field}` is required for this hyperlink type"), + }) +} + +fn coerce_table_rows( + rows: Vec>, + action: &str, +) -> Result>, PresentationArtifactError> { + if rows.is_empty() { + return Err(PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: "`rows` must contain at least one row".to_string(), + }); + } + Ok(rows + .into_iter() + .map(|row| { + row.into_iter() + .map(|value| TableCellSpec { + text: cell_value_to_string(value), + text_style: TextStyle::default(), + background_fill: None, + alignment: None, + rich_text: RichTextState::default(), + borders: None, + }) + .collect() + }) + .collect()) +} + +fn parse_table_border( + document: &PresentationDocument, + border: &TableBorderArgs, + action: &str, + field: &str, +) -> Result { + Ok(TableBorder { + color: normalize_color_with_document(document, &border.color, action, field)?, + width: border.width, + }) +} + +fn parse_table_borders( + document: &PresentationDocument, + borders: Option, + action: &str, +) -> Result, PresentationArtifactError> { + borders + .map(|borders| { + Ok(TableBorders { + outside: borders + .outside + .as_ref() + .map(|border| parse_table_border(document, border, action, "borders.outside")) + .transpose()?, + inside: borders + .inside + .as_ref() + .map(|border| parse_table_border(document, border, action, "borders.inside")) + .transpose()?, + top: borders + .top + .as_ref() + .map(|border| parse_table_border(document, border, action, "borders.top")) + .transpose()?, + bottom: borders + .bottom + .as_ref() + .map(|border| parse_table_border(document, border, action, "borders.bottom")) + .transpose()?, + left: borders + .left + .as_ref() + .map(|border| parse_table_border(document, border, action, "borders.left")) + .transpose()?, + right: borders + .right + .as_ref() + .map(|border| parse_table_border(document, border, action, "borders.right")) + .transpose()?, + }) + }) + .transpose() +} + +fn parse_table_style_options(style_options: Option) -> TableStyleOptions { + style_options + .map(|style_options| TableStyleOptions { + header_row: style_options.header_row.unwrap_or(false), + banded_rows: style_options.banded_rows.unwrap_or(false), + banded_columns: style_options.banded_columns.unwrap_or(false), + first_column: style_options.first_column.unwrap_or(false), + last_column: style_options.last_column.unwrap_or(false), + total_row: style_options.total_row.unwrap_or(false), + }) + .unwrap_or_default() +} + +fn normalize_table_dimensions( + rows: &[Vec], + frame: Rect, + column_widths: Option>, + row_heights: Option>, + action: &str, +) -> Result<(Vec, Vec), PresentationArtifactError> { + let column_count = rows.iter().map(std::vec::Vec::len).max().unwrap_or(1); + let normalized_column_widths = match column_widths { + Some(widths) => { + if widths.len() != column_count { + return Err(PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: format!( + "`column_widths` must contain {column_count} entries for this table" + ), + }); + } + widths + } + None => split_points(frame.width, column_count), + }; + let normalized_row_heights = match row_heights { + Some(heights) => { + if heights.len() != rows.len() { + return Err(PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: format!( + "`row_heights` must contain {} entries for this table", + rows.len() + ), + }); + } + heights + } + None => split_points(frame.height, rows.len()), + }; + Ok((normalized_column_widths, normalized_row_heights)) +} + +fn split_points(total: u32, count: usize) -> Vec { + if count == 0 { + return Vec::new(); + } + let base = total / count as u32; + let remainder = total % count as u32; + (0..count) + .map(|index| base + u32::from(index < remainder as usize)) + .collect() +} + +fn parse_alignment(value: &str, action: &str) -> Result { + match value { + "left" => Ok(TextAlignment::Left), + "center" | "middle" => Ok(TextAlignment::Center), + "right" => Ok(TextAlignment::Right), + "justify" => Ok(TextAlignment::Justify), + _ => Err(PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("unsupported alignment `{value}`"), + }), + } +} + +fn normalize_theme(args: ThemeArgs, action: &str) -> Result { + let color_scheme = args + .color_scheme + .into_iter() + .map(|(key, value)| { + normalize_color(&value, action, &key) + .map(|normalized| (key.to_ascii_lowercase(), normalized)) + }) + .collect::, _>>()?; + Ok(ThemeState { + color_scheme, + major_font: args.major_font, + minor_font: args.minor_font, + }) +} + +fn normalize_style_name( + style_name: &str, + action: &str, +) -> Result { + let normalized_style_name = style_name.trim().to_ascii_lowercase(); + if normalized_style_name.is_empty() { + return Err(PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: "`name` must not be empty".to_string(), + }); + } + Ok(normalized_style_name) +} + +fn built_in_text_styles(theme: &ThemeState) -> HashMap { + ["title", "heading1", "body", "list", "numberedlist"] + .into_iter() + .filter_map(|name| built_in_text_style(theme, name).map(|style| (name.to_string(), style))) + .collect() +} + +fn built_in_text_style(theme: &ThemeState, style_name: &str) -> Option { + let default_color = theme.resolve_color("tx1"); + let default_font = theme + .major_font + .clone() + .or_else(|| theme.minor_font.clone()); + let body_font = theme + .minor_font + .clone() + .or_else(|| theme.major_font.clone()); + let style = match style_name { + "title" => TextStyle { + style_name: Some("title".to_string()), + font_size: Some(28), + font_family: default_font, + color: default_color, + alignment: Some(TextAlignment::Left), + bold: true, + italic: false, + underline: false, + }, + "heading1" => TextStyle { + style_name: Some("heading1".to_string()), + font_size: Some(22), + font_family: default_font, + color: default_color, + alignment: Some(TextAlignment::Left), + bold: true, + italic: false, + underline: false, + }, + "body" => TextStyle { + style_name: Some("body".to_string()), + font_size: Some(14), + font_family: body_font, + color: default_color, + alignment: Some(TextAlignment::Left), + bold: false, + italic: false, + underline: false, + }, + "list" => TextStyle { + style_name: Some("list".to_string()), + font_size: Some(14), + font_family: body_font, + color: default_color, + alignment: Some(TextAlignment::Left), + bold: false, + italic: false, + underline: false, + }, + "numberedlist" => TextStyle { + style_name: Some("numberedlist".to_string()), + font_size: Some(14), + font_family: body_font, + color: default_color, + alignment: Some(TextAlignment::Left), + bold: false, + italic: false, + underline: false, + }, + _ => return None, + }; + Some(style) +} + +fn named_text_style_to_json(style: &NamedTextStyle, id_prefix: &str) -> Value { + serde_json::json!({ + "kind": "textStyle", + "id": format!("{id_prefix}/{}", style.name), + "name": style.name, + "builtIn": style.built_in, + "style": text_style_to_proto(&style.style), + }) +} + +fn parse_slide_size(value: &Value, action: &str) -> Result { + #[derive(Deserialize)] + struct SlideSizeArgs { + width: u32, + height: u32, + } + + let slide_size: SlideSizeArgs = serde_json::from_value(value.clone()).map_err(|error| { + PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("invalid slide_size: {error}"), + } + })?; + Ok(Rect { + left: 0, + top: 0, + width: slide_size.width, + height: slide_size.height, + }) +} + +fn apply_layout_to_slide( + document: &mut PresentationDocument, + slide: &mut PresentationSlide, + layout_ref: &str, + action: &str, +) -> Result<(), PresentationArtifactError> { + let layout = document.get_layout(layout_ref, action)?.clone(); + let placeholders = resolved_layout_placeholders(document, &layout.layout_id, action)?; + slide.layout_id = Some(layout.layout_id); + for resolved in placeholders { + slide.elements.push(materialize_placeholder_element( + document.next_element_id(), + resolved.definition, + slide.elements.len(), + )); + } + Ok(()) +} + +fn materialize_placeholder_element( + element_id: String, + placeholder: PlaceholderDefinition, + z_order: usize, +) -> PresentationElement { + let placeholder_ref = Some(PlaceholderRef { + name: placeholder.name.clone(), + placeholder_type: placeholder.placeholder_type.clone(), + index: placeholder.index, + }); + if placeholder_is_image(&placeholder.placeholder_type) { + return PresentationElement::Image(ImageElement { + element_id, + frame: placeholder.frame, + payload: None, + fit_mode: ImageFitMode::Stretch, + crop: None, + rotation_degrees: None, + flip_horizontal: false, + flip_vertical: false, + lock_aspect_ratio: true, + alt_text: Some(placeholder.name.clone()), + prompt: placeholder + .text + .clone() + .or_else(|| Some(format!("Image placeholder: {}", placeholder.name))), + is_placeholder: true, + placeholder: placeholder_ref, + z_order, + }); + } + if placeholder.geometry == ShapeGeometry::Rectangle { + PresentationElement::Text(TextElement { + element_id, + text: placeholder.text.unwrap_or_default(), + frame: placeholder.frame, + fill: None, + style: TextStyle::default(), + hyperlink: None, + rich_text: RichTextState::default(), + placeholder: placeholder_ref, + z_order, + }) + } else { + PresentationElement::Shape(ShapeElement { + element_id, + geometry: placeholder.geometry, + frame: placeholder.frame, + fill: None, + stroke: None, + text: placeholder.text, + text_style: TextStyle::default(), + hyperlink: None, + rich_text: None, + placeholder: placeholder_ref, + rotation_degrees: None, + flip_horizontal: false, + flip_vertical: false, + z_order, + }) + } +} + +fn resolved_layout_placeholders( + document: &PresentationDocument, + layout_id: &str, + action: &str, +) -> Result, PresentationArtifactError> { + let mut lineage = Vec::new(); + collect_layout_lineage( + document, + layout_id, + action, + &mut HashSet::new(), + &mut lineage, + )?; + let mut resolved: Vec = Vec::new(); + for layout in lineage { + for placeholder in &layout.placeholders { + if let Some(index) = resolved.iter().position(|entry| { + placeholder_key(&entry.definition) == placeholder_key(placeholder) + }) { + resolved[index] = ResolvedPlaceholder { + source_layout_id: layout.layout_id.clone(), + definition: placeholder.clone(), + }; + } else { + resolved.push(ResolvedPlaceholder { + source_layout_id: layout.layout_id.clone(), + definition: placeholder.clone(), + }); + } + } + } + Ok(resolved) +} + +fn collect_layout_lineage<'a>( + document: &'a PresentationDocument, + layout_id: &str, + action: &str, + seen: &mut HashSet, + lineage: &mut Vec<&'a LayoutDocument>, +) -> Result<(), PresentationArtifactError> { + if !seen.insert(layout_id.to_string()) { + return Err(PresentationArtifactError::UnsupportedFeature { + action: action.to_string(), + message: format!("layout inheritance cycle detected at `{layout_id}`"), + }); + } + let layout = document.get_layout(layout_id, action)?; + if let Some(parent_layout_id) = &layout.parent_layout_id { + collect_layout_lineage(document, parent_layout_id, action, seen, lineage)?; + } + lineage.push(layout); + Ok(()) +} + +fn placeholder_key(placeholder: &PlaceholderDefinition) -> (String, String, Option) { + ( + placeholder.name.to_ascii_lowercase(), + placeholder.placeholder_type.to_ascii_lowercase(), + placeholder.index, + ) +} + +fn layout_placeholder_list( + document: &PresentationDocument, + layout_id: &str, + action: &str, +) -> Result, PresentationArtifactError> { + resolved_layout_placeholders(document, layout_id, action).map(|placeholders| { + placeholders + .into_iter() + .map(|placeholder| PlaceholderListEntry { + scope: "layout".to_string(), + source_layout_id: Some(placeholder.source_layout_id), + slide_index: None, + element_id: None, + name: placeholder.definition.name, + placeholder_type: placeholder.definition.placeholder_type, + index: placeholder.definition.index, + geometry: Some(format!("{:?}", placeholder.definition.geometry)), + text_preview: placeholder.definition.text, + }) + .collect() + }) +} + +fn placeholder_is_image(placeholder_type: &str) -> bool { + matches!( + placeholder_type.to_ascii_lowercase().as_str(), + "image" | "picture" | "pic" | "photo" + ) +} + +fn slide_placeholder_list( + slide: &PresentationSlide, + slide_index: usize, +) -> Vec { + slide + .elements + .iter() + .filter_map(|element| match element { + PresentationElement::Text(text) => { + text.placeholder + .as_ref() + .map(|placeholder| PlaceholderListEntry { + scope: "slide".to_string(), + source_layout_id: slide.layout_id.clone(), + slide_index: Some(slide_index), + element_id: Some(text.element_id.clone()), + name: placeholder.name.clone(), + placeholder_type: placeholder.placeholder_type.clone(), + index: placeholder.index, + geometry: Some("Rectangle".to_string()), + text_preview: Some(text.text.clone()), + }) + } + PresentationElement::Shape(shape) => { + shape + .placeholder + .as_ref() + .map(|placeholder| PlaceholderListEntry { + scope: "slide".to_string(), + source_layout_id: slide.layout_id.clone(), + slide_index: Some(slide_index), + element_id: Some(shape.element_id.clone()), + name: placeholder.name.clone(), + placeholder_type: placeholder.placeholder_type.clone(), + index: placeholder.index, + geometry: Some(format!("{:?}", shape.geometry)), + text_preview: shape.text.clone(), + }) + } + PresentationElement::Image(image) => { + image + .placeholder + .as_ref() + .map(|placeholder| PlaceholderListEntry { + scope: "slide".to_string(), + source_layout_id: slide.layout_id.clone(), + slide_index: Some(slide_index), + element_id: Some(image.element_id.clone()), + name: placeholder.name.clone(), + placeholder_type: placeholder.placeholder_type.clone(), + index: placeholder.index, + geometry: Some("Image".to_string()), + text_preview: image.prompt.clone(), + }) + } + PresentationElement::Connector(_) + | PresentationElement::Table(_) + | PresentationElement::Chart(_) => None, + }) + .collect() +} diff --git a/codex-rs/artifact-presentation/src/presentation_artifact/pptx.rs b/codex-rs/artifact-presentation/src/presentation_artifact/pptx.rs new file mode 100644 index 0000000000..a06abc7017 --- /dev/null +++ b/codex-rs/artifact-presentation/src/presentation_artifact/pptx.rs @@ -0,0 +1,951 @@ +const CODEX_METADATA_ENTRY: &str = "ppt/codex-document.json"; + +fn import_codex_metadata_document(path: &Path) -> Result, String> { + let file = std::fs::File::open(path).map_err(|error| error.to_string())?; + let mut archive = ZipArchive::new(file).map_err(|error| error.to_string())?; + let mut entry = match archive.by_name(CODEX_METADATA_ENTRY) { + Ok(entry) => entry, + Err(zip::result::ZipError::FileNotFound) => return Ok(None), + Err(error) => return Err(error.to_string()), + }; + let mut bytes = Vec::new(); + entry.read_to_end(&mut bytes) + .map_err(|error| error.to_string())?; + serde_json::from_slice(&bytes) + .map(Some) + .map_err(|error| error.to_string()) +} + +fn build_pptx_bytes(document: &PresentationDocument, action: &str) -> Result, String> { + let bytes = document + .to_ppt_rs() + .build() + .map_err(|error| format!("{action}: {error}"))?; + patch_pptx_package(bytes, document).map_err(|error| format!("{action}: {error}")) +} + +struct SlideImageAsset { + xml: String, + relationship_xml: String, + media_path: String, + media_bytes: Vec, + extension: String, +} + +fn normalized_image_extension(format: &str) -> String { + match format.to_ascii_lowercase().as_str() { + "jpeg" => "jpg".to_string(), + other => other.to_string(), + } +} + +fn image_relationship_xml(relationship_id: &str, target: &str) -> String { + format!( + r#""#, + ppt_rs::escape_xml(target) + ) +} + +fn image_picture_xml( + image: &ImageElement, + shape_id: usize, + relationship_id: &str, + frame: Rect, + crop: Option, +) -> String { + let blip_fill = if let Some((crop_left, crop_top, crop_right, crop_bottom)) = crop { + format!( + r#" + + + + + +"#, + (crop_left * 100_000.0).round() as u32, + (crop_top * 100_000.0).round() as u32, + (crop_right * 100_000.0).round() as u32, + (crop_bottom * 100_000.0).round() as u32, + ) + } else { + format!( + r#" + + + + +"# + ) + }; + let descr = image + .alt_text + .as_deref() + .map(|alt| format!(r#" descr="{}""#, ppt_rs::escape_xml(alt))) + .unwrap_or_default(); + let no_change_aspect = if image.lock_aspect_ratio { 1 } else { 0 }; + let rotation = image + .rotation_degrees + .map(|rotation| format!(r#" rot="{}""#, i64::from(rotation) * 60_000)) + .unwrap_or_default(); + let flip_horizontal = if image.flip_horizontal { + r#" flipH="1""# + } else { + "" + }; + let flip_vertical = if image.flip_vertical { + r#" flipV="1""# + } else { + "" + }; + format!( + r#" + + + + + + + +{blip_fill} + + + + + + + + + +"#, + points_to_emu(frame.left), + points_to_emu(frame.top), + points_to_emu(frame.width), + points_to_emu(frame.height), + ) +} + +fn slide_image_assets( + slide: &PresentationSlide, + next_media_index: &mut usize, +) -> Vec { + let mut ordered = slide.elements.iter().collect::>(); + ordered.sort_by_key(|element| element.z_order()); + let shape_count = ordered + .iter() + .filter(|element| { + matches!( + element, + PresentationElement::Text(_) + | PresentationElement::Shape(_) + | PresentationElement::Image(ImageElement { payload: None, .. }) + ) + }) + .count() + + usize::from(slide.background_fill.is_some()); + let mut image_index = 0_usize; + let mut assets = Vec::new(); + for element in ordered { + let PresentationElement::Image(image) = element else { + continue; + }; + let Some(payload) = &image.payload else { + continue; + }; + let (left, top, width, height, fitted_crop) = if image.fit_mode != ImageFitMode::Stretch { + fit_image(image) + } else { + ( + image.frame.left, + image.frame.top, + image.frame.width, + image.frame.height, + None, + ) + }; + image_index += 1; + let relationship_id = format!("rIdImage{image_index}"); + let extension = normalized_image_extension(&payload.format); + let media_name = format!("image{next_media_index}.{extension}"); + *next_media_index += 1; + assets.push(SlideImageAsset { + xml: image_picture_xml( + image, + 20 + shape_count + image_index - 1, + &relationship_id, + Rect { + left, + top, + width, + height, + }, + image.crop.or(fitted_crop), + ), + relationship_xml: image_relationship_xml( + &relationship_id, + &format!("../media/{media_name}"), + ), + media_path: format!("ppt/media/{media_name}"), + media_bytes: payload.bytes.clone(), + extension, + }); + } + assets +} + +fn patch_pptx_package( + source_bytes: Vec, + document: &PresentationDocument, +) -> Result, String> { + let mut archive = + ZipArchive::new(Cursor::new(source_bytes)).map_err(|error| error.to_string())?; + let mut writer = ZipWriter::new(Cursor::new(Vec::new())); + let mut next_media_index = 1_usize; + let mut pending_slide_relationships = HashMap::new(); + let mut pending_slide_images = HashMap::new(); + let mut pending_media = Vec::new(); + let mut image_extensions = BTreeSet::new(); + for (slide_index, slide) in document.slides.iter().enumerate() { + let slide_number = slide_index + 1; + let images = slide_image_assets(slide, &mut next_media_index); + let mut relationships = slide_hyperlink_relationships(slide); + relationships.extend(images.iter().map(|image| image.relationship_xml.clone())); + if !relationships.is_empty() { + pending_slide_relationships.insert(slide_number, relationships); + } + if !images.is_empty() { + image_extensions.extend(images.iter().map(|image| image.extension.clone())); + pending_media.extend( + images + .iter() + .map(|image| (image.media_path.clone(), image.media_bytes.clone())), + ); + pending_slide_images.insert(slide_number, images); + } + } + + for index in 0..archive.len() { + let mut file = archive.by_index(index).map_err(|error| error.to_string())?; + if file.is_dir() { + continue; + } + let name = file.name().to_string(); + if name == CODEX_METADATA_ENTRY { + continue; + } + let options = file.options(); + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes) + .map_err(|error| error.to_string())?; + writer + .start_file(&name, options) + .map_err(|error| error.to_string())?; + if name == "[Content_Types].xml" { + writer + .write_all(update_content_types_xml(bytes, &image_extensions)?.as_bytes()) + .map_err(|error| error.to_string())?; + continue; + } + if name == "ppt/presentation.xml" { + writer + .write_all( + update_presentation_xml_dimensions(bytes, document.slide_size)?.as_bytes(), + ) + .map_err(|error| error.to_string())?; + continue; + } + if let Some(slide_number) = parse_slide_xml_path(&name) { + writer + .write_all( + update_slide_xml( + bytes, + &document.slides[slide_number - 1], + pending_slide_images + .get(&slide_number) + .map(std::vec::Vec::as_slice) + .unwrap_or(&[]), + )? + .as_bytes(), + ) + .map_err(|error| error.to_string())?; + continue; + } + if let Some(slide_number) = parse_slide_relationships_path(&name) + && let Some(relationships) = pending_slide_relationships.remove(&slide_number) + { + writer + .write_all(update_slide_relationships_xml(bytes, &relationships)?.as_bytes()) + .map_err(|error| error.to_string())?; + continue; + } + writer + .write_all(&bytes) + .map_err(|error| error.to_string())?; + } + + for (slide_number, relationships) in pending_slide_relationships { + writer + .start_file( + format!("ppt/slides/_rels/slide{slide_number}.xml.rels"), + SimpleFileOptions::default(), + ) + .map_err(|error| error.to_string())?; + writer + .write_all(slide_relationships_xml(&relationships).as_bytes()) + .map_err(|error| error.to_string())?; + } + + for (path, bytes) in pending_media { + writer + .start_file(path, SimpleFileOptions::default()) + .map_err(|error| error.to_string())?; + writer + .write_all(&bytes) + .map_err(|error| error.to_string())?; + } + + writer + .start_file(CODEX_METADATA_ENTRY, SimpleFileOptions::default()) + .map_err(|error| error.to_string())?; + writer + .write_all( + &serde_json::to_vec(document).map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string())?; + + writer + .finish() + .map_err(|error| error.to_string()) + .map(Cursor::into_inner) +} + +fn update_presentation_xml_dimensions( + existing_bytes: Vec, + slide_size: Rect, +) -> Result { + let existing = String::from_utf8(existing_bytes).map_err(|error| error.to_string())?; + let updated = replace_self_closing_xml_tag( + &existing, + "p:sldSz", + &format!( + r#""#, + points_to_emu(slide_size.width), + points_to_emu(slide_size.height) + ), + )?; + replace_self_closing_xml_tag( + &updated, + "p:notesSz", + &format!( + r#""#, + points_to_emu(slide_size.height), + points_to_emu(slide_size.width) + ), + ) +} + +fn replace_self_closing_xml_tag(xml: &str, tag: &str, replacement: &str) -> Result { + let start = xml + .find(&format!("<{tag} ")) + .ok_or_else(|| format!("presentation xml is missing `<{tag} .../>`"))?; + let end = xml[start..] + .find("/>") + .map(|offset| start + offset + 2) + .ok_or_else(|| format!("presentation xml tag `{tag}` is not self-closing"))?; + Ok(format!("{}{replacement}{}", &xml[..start], &xml[end..])) +} + +fn slide_hyperlink_relationships(slide: &PresentationSlide) -> Vec { + let mut ordered = slide.elements.iter().collect::>(); + ordered.sort_by_key(|element| element.z_order()); + let mut hyperlink_index = 1_u32; + let mut relationships = Vec::new(); + for element in ordered { + let Some(hyperlink) = (match element { + PresentationElement::Text(text) => text.hyperlink.as_ref(), + PresentationElement::Shape(shape) => shape.hyperlink.as_ref(), + PresentationElement::Connector(_) + | PresentationElement::Image(_) + | PresentationElement::Table(_) + | PresentationElement::Chart(_) => None, + }) else { + continue; + }; + let relationship_id = format!("rIdHyperlink{hyperlink_index}"); + hyperlink_index += 1; + relationships.push(hyperlink.relationship_xml(&relationship_id)); + } + relationships +} + +fn parse_slide_relationships_path(path: &str) -> Option { + path.strip_prefix("ppt/slides/_rels/slide")? + .strip_suffix(".xml.rels")? + .parse::() + .ok() +} + +fn parse_slide_xml_path(path: &str) -> Option { + path.strip_prefix("ppt/slides/slide")? + .strip_suffix(".xml")? + .parse::() + .ok() +} + +fn update_slide_relationships_xml( + existing_bytes: Vec, + relationships: &[String], +) -> Result { + let existing = String::from_utf8(existing_bytes).map_err(|error| error.to_string())?; + let injected = relationships.join("\n"); + existing + .contains("") + .then(|| existing.replace("", &format!("{injected}\n"))) + .ok_or_else(|| { + "slide relationships xml is missing a closing ``".to_string() + }) +} + +fn slide_relationships_xml(relationships: &[String]) -> String { + let body = relationships.join("\n"); + format!( + r#" + +{body} +"# + ) +} + +fn update_content_types_xml( + existing_bytes: Vec, + image_extensions: &BTreeSet, +) -> Result { + let existing = String::from_utf8(existing_bytes).map_err(|error| error.to_string())?; + if image_extensions.is_empty() { + return Ok(existing); + } + let existing_lower = existing.to_ascii_lowercase(); + let additions = image_extensions + .iter() + .filter(|extension| { + !existing_lower.contains(&format!( + r#"extension="{}""#, + extension.to_ascii_lowercase() + )) + }) + .map(|extension| generate_image_content_type(extension)) + .collect::>(); + if additions.is_empty() { + return Ok(existing); + } + existing + .contains("") + .then(|| existing.replace("", &format!("{}\n", additions.join("\n")))) + .ok_or_else(|| "content types xml is missing a closing ``".to_string()) +} + +fn update_slide_xml( + existing_bytes: Vec, + slide: &PresentationSlide, + slide_images: &[SlideImageAsset], +) -> Result { + let existing = String::from_utf8(existing_bytes).map_err(|error| error.to_string())?; + let existing = replace_image_placeholders(existing, slide_images)?; + let existing = apply_shape_block_patches(existing, slide)?; + let table_xml = slide_table_xml(slide); + if table_xml.is_empty() { + return Ok(existing); + } + existing + .contains("") + .then(|| existing.replace("", &format!("{table_xml}\n"))) + .ok_or_else(|| "slide xml is missing a closing ``".to_string()) +} + +fn replace_image_placeholders( + existing: String, + slide_images: &[SlideImageAsset], +) -> Result { + if slide_images.is_empty() { + return Ok(existing); + } + let mut updated = String::with_capacity(existing.len()); + let mut remaining = existing.as_str(); + for image in slide_images { + let marker = remaining + .find("name=\"Image Placeholder: ") + .ok_or_else(|| { + "slide xml is missing an image placeholder block for exported images".to_string() + })?; + let start = remaining[..marker].rfind("").ok_or_else(|| { + "slide xml is missing an opening `` for image placeholder".to_string() + })?; + let end = remaining[marker..] + .find("") + .map(|offset| marker + offset + "".len()) + .ok_or_else(|| { + "slide xml is missing a closing `` for image placeholder".to_string() + })?; + updated.push_str(&remaining[..start]); + updated.push_str(&image.xml); + remaining = &remaining[end..]; + } + updated.push_str(remaining); + Ok(updated) +} + +#[derive(Clone, Copy)] +struct ShapeXmlPatch { + line_style: Option, + flip_horizontal: bool, + flip_vertical: bool, +} + +fn apply_shape_block_patches( + existing: String, + slide: &PresentationSlide, +) -> Result { + let mut patches = Vec::new(); + if slide.background_fill.is_some() { + patches.push(None); + } + let mut ordered = slide.elements.iter().collect::>(); + ordered.sort_by_key(|element| element.z_order()); + for element in ordered { + match element { + PresentationElement::Text(_) => patches.push(None), + PresentationElement::Shape(shape) => patches.push(Some(ShapeXmlPatch { + line_style: shape + .stroke + .as_ref() + .map(|stroke| stroke.style) + .filter(|style| *style != LineStyle::Solid), + flip_horizontal: shape.flip_horizontal, + flip_vertical: shape.flip_vertical, + })), + PresentationElement::Image(ImageElement { payload: None, .. }) => patches.push(None), + PresentationElement::Connector(_) + | PresentationElement::Image(_) + | PresentationElement::Table(_) + | PresentationElement::Chart(_) => {} + } + } + if patches.iter().all(|patch| { + patch.is_none_or(|patch| { + patch.line_style.is_none() && !patch.flip_horizontal && !patch.flip_vertical + }) + }) { + return Ok(existing); + } + + let mut updated = String::with_capacity(existing.len()); + let mut remaining = existing.as_str(); + for patch in patches { + let Some(start) = remaining.find("") else { + return Err("slide xml is missing an expected `` block".to_string()); + }; + let end = remaining[start..] + .find("") + .map(|offset| start + offset + "".len()) + .ok_or_else(|| "slide xml is missing a closing `` block".to_string())?; + updated.push_str(&remaining[..start]); + let block = &remaining[start..end]; + if let Some(patch) = patch { + updated.push_str(&patch_shape_block(block, patch)?); + } else { + updated.push_str(block); + } + remaining = &remaining[end..]; + } + updated.push_str(remaining); + Ok(updated) +} + +fn patch_shape_block(block: &str, patch: ShapeXmlPatch) -> Result { + let block = if let Some(line_style) = patch.line_style { + patch_shape_block_dash(block, line_style)? + } else { + block.to_string() + }; + if patch.flip_horizontal || patch.flip_vertical { + patch_shape_block_flip(&block, patch.flip_horizontal, patch.flip_vertical) + } else { + Ok(block) + } +} + +fn patch_shape_block_dash(block: &str, line_style: LineStyle) -> Result { + let Some(line_start) = block.find("` entry for stroke styling".to_string()); + }; + if let Some(dash_start) = block[line_start..].find("") + .map(|offset| dash_start + offset + 2) + .ok_or_else(|| "shape line dash entry is missing a closing `/>`".to_string())?; + let mut patched = String::with_capacity(block.len() + 32); + patched.push_str(&block[..dash_start]); + patched.push_str(&format!( + r#""#, + line_style.to_ppt_xml() + )); + patched.push_str(&block[dash_end..]); + return Ok(patched); + } + + if let Some(line_end) = block[line_start..].find("") { + let line_end = line_start + line_end; + let mut patched = String::with_capacity(block.len() + 32); + patched.push_str(&block[..line_end]); + patched.push_str(&format!( + r#""#, + line_style.to_ppt_xml() + )); + patched.push_str(&block[line_end..]); + return Ok(patched); + } + + let line_end = block[line_start..] + .find("/>") + .map(|offset| line_start + offset + 2) + .ok_or_else(|| "shape line entry is missing a closing marker".to_string())?; + let line_tag = &block[line_start..line_end - 2]; + let mut patched = String::with_capacity(block.len() + 48); + patched.push_str(&block[..line_start]); + patched.push_str(line_tag); + patched.push('>'); + patched.push_str(&format!( + r#""#, + line_style.to_ppt_xml() + )); + patched.push_str(""); + patched.push_str(&block[line_end..]); + Ok(patched) +} + +fn patch_shape_block_flip( + block: &str, + flip_horizontal: bool, + flip_vertical: bool, +) -> Result { + let Some(xfrm_start) = block.find("` entry for flip styling".to_string()); + }; + let tag_end = block[xfrm_start..] + .find('>') + .map(|offset| xfrm_start + offset) + .ok_or_else(|| "shape transform entry is missing a closing `>`".to_string())?; + let tag = &block[xfrm_start..=tag_end]; + let mut patched_tag = tag.to_string(); + patched_tag = upsert_xml_attribute( + &patched_tag, + "flipH", + if flip_horizontal { "1" } else { "0" }, + ); + patched_tag = + upsert_xml_attribute(&patched_tag, "flipV", if flip_vertical { "1" } else { "0" }); + Ok(format!( + "{}{}{}", + &block[..xfrm_start], + patched_tag, + &block[tag_end + 1..] + )) +} + +fn upsert_xml_attribute(tag: &str, attribute: &str, value: &str) -> String { + let needle = format!(r#"{attribute}=""#); + if let Some(start) = tag.find(&needle) { + let value_start = start + needle.len(); + if let Some(end_offset) = tag[value_start..].find('"') { + let end = value_start + end_offset; + return format!("{}{}{}", &tag[..value_start], value, &tag[end..]); + } + } + let insert_at = tag.len() - 1; + format!(r#"{} {attribute}="{value}""#, &tag[..insert_at]) + &tag[insert_at..] +} + +fn slide_table_xml(slide: &PresentationSlide) -> String { + let mut ordered = slide.elements.iter().collect::>(); + ordered.sort_by_key(|element| element.z_order()); + let mut table_index = 0_usize; + ordered + .into_iter() + .filter_map(|element| { + let PresentationElement::Table(table) = element else { + return None; + }; + table_index += 1; + let rows = table + .rows + .clone() + .into_iter() + .enumerate() + .map(|(row_index, row)| { + let cells = row + .into_iter() + .enumerate() + .map(|(column_index, cell)| { + build_table_cell(cell, &table.merges, row_index, column_index) + }) + .collect::>(); + let mut table_row = TableRow::new(cells); + if let Some(height) = table.row_heights.get(row_index) { + table_row = table_row.with_height(points_to_emu(*height)); + } + Some(table_row) + }) + .collect::>>()?; + Some(ppt_rs::generator::table::generate_table_xml( + &ppt_rs::generator::table::Table::new( + rows, + table + .column_widths + .iter() + .copied() + .map(points_to_emu) + .collect(), + points_to_emu(table.frame.left), + points_to_emu(table.frame.top), + ), + 300 + table_index, + )) + }) + .collect::>() + .join("\n") +} + +fn write_preview_images( + document: &PresentationDocument, + output_dir: &Path, + action: &str, +) -> Result<(), PresentationArtifactError> { + let pptx_path = output_dir.join("preview.pptx"); + let bytes = build_pptx_bytes(document, action).map_err(|message| { + PresentationArtifactError::ExportFailed { + path: pptx_path.clone(), + message, + } + })?; + std::fs::write(&pptx_path, bytes).map_err(|error| PresentationArtifactError::ExportFailed { + path: pptx_path.clone(), + message: error.to_string(), + })?; + render_pptx_to_pngs(&pptx_path, output_dir, action) +} + +fn render_pptx_to_pngs( + pptx_path: &Path, + output_dir: &Path, + action: &str, +) -> Result<(), PresentationArtifactError> { + let soffice_cmd = if cfg!(target_os = "macos") + && Path::new("/Applications/LibreOffice.app/Contents/MacOS/soffice").exists() + { + "/Applications/LibreOffice.app/Contents/MacOS/soffice" + } else { + "soffice" + }; + let conversion = Command::new(soffice_cmd) + .arg("--headless") + .arg("--convert-to") + .arg("pdf") + .arg(pptx_path) + .arg("--outdir") + .arg(output_dir) + .output() + .map_err(|error| PresentationArtifactError::ExportFailed { + path: pptx_path.to_path_buf(), + message: format!("{action}: failed to execute LibreOffice: {error}"), + })?; + if !conversion.status.success() { + return Err(PresentationArtifactError::ExportFailed { + path: pptx_path.to_path_buf(), + message: format!( + "{action}: LibreOffice conversion failed: {}", + String::from_utf8_lossy(&conversion.stderr) + ), + }); + } + + let pdf_path = output_dir.join( + pptx_path + .file_stem() + .and_then(|stem| stem.to_str()) + .map(|stem| format!("{stem}.pdf")) + .ok_or_else(|| PresentationArtifactError::ExportFailed { + path: pptx_path.to_path_buf(), + message: format!("{action}: preview pptx filename is invalid"), + })?, + ); + let prefix = output_dir.join("slide"); + let conversion = Command::new("pdftoppm") + .arg("-png") + .arg(&pdf_path) + .arg(&prefix) + .output() + .map_err(|error| PresentationArtifactError::ExportFailed { + path: pdf_path.clone(), + message: format!("{action}: failed to execute pdftoppm: {error}"), + })?; + std::fs::remove_file(&pdf_path).ok(); + if !conversion.status.success() { + return Err(PresentationArtifactError::ExportFailed { + path: output_dir.to_path_buf(), + message: format!( + "{action}: pdftoppm conversion failed: {}", + String::from_utf8_lossy(&conversion.stderr) + ), + }); + } + Ok(()) +} + +pub(crate) fn write_preview_image( + source_path: &Path, + target_path: &Path, + format: PreviewOutputFormat, + scale: f32, + quality: u8, + action: &str, +) -> Result<(), PresentationArtifactError> { + if matches!(format, PreviewOutputFormat::Png) && scale == 1.0 { + std::fs::rename(source_path, target_path).map_err(|error| { + PresentationArtifactError::ExportFailed { + path: target_path.to_path_buf(), + message: error.to_string(), + } + })?; + return Ok(()); + } + let mut preview = + image::open(source_path).map_err(|error| PresentationArtifactError::ExportFailed { + path: source_path.to_path_buf(), + message: format!("{action}: {error}"), + })?; + if scale != 1.0 { + let width = (preview.width() as f32 * scale).round().max(1.0) as u32; + let height = (preview.height() as f32 * scale).round().max(1.0) as u32; + preview = preview.resize_exact(width, height, FilterType::Lanczos3); + } + let file = std::fs::File::create(target_path).map_err(|error| { + PresentationArtifactError::ExportFailed { + path: target_path.to_path_buf(), + message: error.to_string(), + } + })?; + let mut writer = std::io::BufWriter::new(file); + match format { + PreviewOutputFormat::Png => { + preview + .write_to(&mut writer, ImageFormat::Png) + .map_err(|error| PresentationArtifactError::ExportFailed { + path: target_path.to_path_buf(), + message: format!("{action}: {error}"), + })? + } + PreviewOutputFormat::Jpeg => { + let rgb = preview.to_rgb8(); + let mut encoder = JpegEncoder::new_with_quality(&mut writer, quality); + encoder.encode_image(&rgb).map_err(|error| { + PresentationArtifactError::ExportFailed { + path: target_path.to_path_buf(), + message: format!("{action}: {error}"), + } + })?; + } + PreviewOutputFormat::Svg => { + let mut png_bytes = Cursor::new(Vec::new()); + preview + .write_to(&mut png_bytes, ImageFormat::Png) + .map_err(|error| PresentationArtifactError::ExportFailed { + path: target_path.to_path_buf(), + message: format!("{action}: {error}"), + })?; + let embedded_png = BASE64_STANDARD.encode(png_bytes.into_inner()); + let svg = format!( + r#""#, + preview.width(), + preview.height(), + preview.width(), + preview.height(), + preview.width(), + preview.height(), + ); + writer.write_all(svg.as_bytes()).map_err(|error| { + PresentationArtifactError::ExportFailed { + path: target_path.to_path_buf(), + message: format!("{action}: {error}"), + } + })?; + } + } + std::fs::remove_file(source_path).ok(); + Ok(()) +} + +fn collect_pngs(output_dir: &Path) -> Result, PresentationArtifactError> { + let mut files = std::fs::read_dir(output_dir) + .map_err(|error| PresentationArtifactError::ExportFailed { + path: output_dir.to_path_buf(), + message: error.to_string(), + })? + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("png")) + .collect::>(); + files.sort(); + Ok(files) +} + +fn parse_preview_output_format( + format: Option<&str>, + path: &Path, + action: &str, +) -> Result { + let value = format + .map(str::to_owned) + .or_else(|| { + path.extension() + .and_then(|extension| extension.to_str()) + .map(str::to_owned) + }) + .unwrap_or_else(|| "png".to_string()); + match value.to_ascii_lowercase().as_str() { + "png" => Ok(PreviewOutputFormat::Png), + "jpg" | "jpeg" => Ok(PreviewOutputFormat::Jpeg), + "svg" => Ok(PreviewOutputFormat::Svg), + other => Err(PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("preview format `{other}` is not supported"), + }), + } +} + +fn normalize_preview_scale( + scale: Option, + action: &str, +) -> Result { + let scale = scale.unwrap_or(1.0); + if !scale.is_finite() || scale <= 0.0 { + return Err(PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: "`scale` must be a positive number".to_string(), + }); + } + Ok(scale) +} + +fn normalize_preview_quality( + quality: Option, + action: &str, +) -> Result { + let quality = quality.unwrap_or(90); + if quality == 0 || quality > 100 { + return Err(PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: "`quality` must be between 1 and 100".to_string(), + }); + } + Ok(quality) +} diff --git a/codex-rs/artifact-presentation/src/presentation_artifact/proto.rs b/codex-rs/artifact-presentation/src/presentation_artifact/proto.rs new file mode 100644 index 0000000000..05741303ac --- /dev/null +++ b/codex-rs/artifact-presentation/src/presentation_artifact/proto.rs @@ -0,0 +1,614 @@ +fn document_to_proto( + document: &PresentationDocument, + action: &str, +) -> Result { + let layouts = document + .layouts + .iter() + .map(|layout| layout_to_proto(document, layout, action)) + .collect::, _>>()?; + let slides = document + .slides + .iter() + .enumerate() + .map(|(slide_index, slide)| slide_to_proto(slide, slide_index)) + .collect::>(); + Ok(serde_json::json!({ + "kind": "presentation", + "artifactId": document.artifact_id, + "anchor": format!("pr/{}", document.artifact_id), + "name": document.name, + "slideSize": rect_to_proto(document.slide_size), + "activeSlideIndex": document.active_slide_index, + "activeSlideId": document.active_slide_index.and_then(|index| document.slides.get(index)).map(|slide| slide.slide_id.clone()), + "theme": serde_json::json!({ + "colorScheme": document.theme.color_scheme, + "hexColorMap": document.theme.color_scheme, + "majorFont": document.theme.major_font, + "minorFont": document.theme.minor_font, + }), + "styles": document + .named_text_styles() + .iter() + .map(|style| named_text_style_to_json(style, "st")) + .collect::>(), + "masters": document.layouts.iter().filter(|layout| layout.kind == LayoutKind::Master).map(|layout| layout.layout_id.clone()).collect::>(), + "layouts": layouts, + "slides": slides, + "commentAuthor": document.comment_self.as_ref().map(comment_author_to_proto), + "commentThreads": document + .comment_threads + .iter() + .map(comment_thread_to_proto) + .collect::>(), + })) +} + +fn layout_to_proto( + document: &PresentationDocument, + layout: &LayoutDocument, + action: &str, +) -> Result { + let placeholders = layout + .placeholders + .iter() + .map(placeholder_definition_to_proto) + .collect::>(); + let resolved_placeholders = resolved_layout_placeholders(document, &layout.layout_id, action)? + .into_iter() + .map(|placeholder| { + let mut value = placeholder_definition_to_proto(&placeholder.definition); + value["sourceLayoutId"] = Value::String(placeholder.source_layout_id); + value + }) + .collect::>(); + Ok(serde_json::json!({ + "layoutId": layout.layout_id, + "anchor": format!("ly/{}", layout.layout_id), + "name": layout.name, + "kind": match layout.kind { + LayoutKind::Layout => "layout", + LayoutKind::Master => "master", + }, + "parentLayoutId": layout.parent_layout_id, + "placeholders": placeholders, + "resolvedPlaceholders": resolved_placeholders, + })) +} + +fn placeholder_definition_to_proto(placeholder: &PlaceholderDefinition) -> Value { + serde_json::json!({ + "name": placeholder.name, + "placeholderType": placeholder.placeholder_type, + "index": placeholder.index, + "text": placeholder.text, + "geometry": format!("{:?}", placeholder.geometry), + "frame": rect_to_proto(placeholder.frame), + }) +} + +fn slide_to_proto(slide: &PresentationSlide, slide_index: usize) -> Value { + serde_json::json!({ + "slideId": slide.slide_id, + "anchor": format!("sl/{}", slide.slide_id), + "index": slide_index, + "layoutId": slide.layout_id, + "backgroundFill": slide.background_fill, + "notes": serde_json::json!({ + "anchor": format!("nt/{}", slide.slide_id), + "text": slide.notes.text, + "visible": slide.notes.visible, + "textPreview": slide.notes.text.replace('\n', " | "), + "textChars": slide.notes.text.chars().count(), + "textLines": slide.notes.text.lines().count(), + "richText": rich_text_to_proto(&slide.notes.text, &slide.notes.rich_text), + }), + "elements": slide.elements.iter().map(element_to_proto).collect::>(), + }) +} + +fn element_to_proto(element: &PresentationElement) -> Value { + match element { + PresentationElement::Text(text) => { + let mut record = serde_json::json!({ + "kind": "text", + "elementId": text.element_id, + "anchor": format!("sh/{}", text.element_id), + "frame": rect_to_proto(text.frame), + "text": text.text, + "textPreview": text.text.replace('\n', " | "), + "textChars": text.text.chars().count(), + "textLines": text.text.lines().count(), + "fill": text.fill, + "style": text_style_to_proto(&text.style), + "richText": rich_text_to_proto(&text.text, &text.rich_text), + "zOrder": text.z_order, + }); + if let Some(placeholder) = &text.placeholder { + record["placeholder"] = placeholder_ref_to_proto(placeholder); + } + if let Some(hyperlink) = &text.hyperlink { + record["hyperlink"] = hyperlink.to_json(); + } + record + } + PresentationElement::Shape(shape) => { + let mut record = serde_json::json!({ + "kind": "shape", + "elementId": shape.element_id, + "anchor": format!("sh/{}", shape.element_id), + "geometry": format!("{:?}", shape.geometry), + "frame": rect_to_proto(shape.frame), + "fill": shape.fill, + "stroke": shape.stroke.as_ref().map(stroke_to_proto), + "text": shape.text, + "textStyle": text_style_to_proto(&shape.text_style), + "richText": shape + .text + .as_ref() + .zip(shape.rich_text.as_ref()) + .map(|(text, rich_text)| rich_text_to_proto(text, rich_text)) + .unwrap_or(Value::Null), + "rotation": shape.rotation_degrees, + "flipHorizontal": shape.flip_horizontal, + "flipVertical": shape.flip_vertical, + "zOrder": shape.z_order, + }); + if let Some(text) = &shape.text { + record["textPreview"] = Value::String(text.replace('\n', " | ")); + record["textChars"] = Value::from(text.chars().count()); + record["textLines"] = Value::from(text.lines().count()); + } + if let Some(placeholder) = &shape.placeholder { + record["placeholder"] = placeholder_ref_to_proto(placeholder); + } + if let Some(hyperlink) = &shape.hyperlink { + record["hyperlink"] = hyperlink.to_json(); + } + record + } + PresentationElement::Connector(connector) => serde_json::json!({ + "kind": "connector", + "elementId": connector.element_id, + "anchor": format!("cn/{}", connector.element_id), + "connectorType": format!("{:?}", connector.connector_type), + "start": serde_json::json!({ + "left": connector.start.left, + "top": connector.start.top, + "unit": "points", + }), + "end": serde_json::json!({ + "left": connector.end.left, + "top": connector.end.top, + "unit": "points", + }), + "line": stroke_to_proto(&connector.line), + "lineStyle": connector.line_style.as_api_str(), + "startArrow": format!("{:?}", connector.start_arrow), + "endArrow": format!("{:?}", connector.end_arrow), + "arrowSize": format!("{:?}", connector.arrow_size), + "label": connector.label, + "zOrder": connector.z_order, + }), + PresentationElement::Image(image) => { + let mut record = serde_json::json!({ + "kind": "image", + "elementId": image.element_id, + "anchor": format!("im/{}", image.element_id), + "frame": rect_to_proto(image.frame), + "fit": format!("{:?}", image.fit_mode), + "crop": image.crop.map(|(left, top, right, bottom)| serde_json::json!({ + "left": left, + "top": top, + "right": right, + "bottom": bottom, + })), + "rotation": image.rotation_degrees, + "flipHorizontal": image.flip_horizontal, + "flipVertical": image.flip_vertical, + "lockAspectRatio": image.lock_aspect_ratio, + "alt": image.alt_text, + "prompt": image.prompt, + "isPlaceholder": image.is_placeholder, + "payload": image.payload.as_ref().map(image_payload_to_proto), + "zOrder": image.z_order, + }); + if let Some(placeholder) = &image.placeholder { + record["placeholder"] = placeholder_ref_to_proto(placeholder); + } + record + } + PresentationElement::Table(table) => serde_json::json!({ + "kind": "table", + "elementId": table.element_id, + "anchor": format!("tb/{}", table.element_id), + "frame": rect_to_proto(table.frame), + "rows": table.rows.iter().map(|row| { + row.iter().map(table_cell_to_proto).collect::>() + }).collect::>(), + "columnWidths": table.column_widths, + "rowHeights": table.row_heights, + "style": table.style, + "styleOptions": table_style_options_to_proto(&table.style_options), + "borders": table.borders.as_ref().map(table_borders_to_proto), + "rightToLeft": table.right_to_left, + "merges": table.merges.iter().map(|merge| serde_json::json!({ + "startRow": merge.start_row, + "endRow": merge.end_row, + "startColumn": merge.start_column, + "endColumn": merge.end_column, + })).collect::>(), + "zOrder": table.z_order, + }), + PresentationElement::Chart(chart) => serde_json::json!({ + "kind": "chart", + "elementId": chart.element_id, + "anchor": format!("ch/{}", chart.element_id), + "frame": rect_to_proto(chart.frame), + "chartType": format!("{:?}", chart.chart_type), + "title": chart.title, + "categories": chart.categories, + "styleIndex": chart.style_index, + "hasLegend": chart.has_legend, + "legend": chart.legend.as_ref().map(chart_legend_to_proto), + "xAxis": chart.x_axis.as_ref().map(chart_axis_to_proto), + "yAxis": chart.y_axis.as_ref().map(chart_axis_to_proto), + "dataLabels": chart.data_labels.as_ref().map(chart_data_labels_to_proto), + "chartFill": chart.chart_fill, + "plotAreaFill": chart.plot_area_fill, + "series": chart.series.iter().map(|series| serde_json::json!({ + "name": series.name, + "values": series.values, + "categories": series.categories, + "xValues": series.x_values, + "fill": series.fill, + "stroke": series.stroke.as_ref().map(stroke_to_proto), + "marker": series.marker.as_ref().map(chart_marker_to_proto), + "dataLabelOverrides": series + .data_label_overrides + .iter() + .map(chart_data_label_override_to_proto) + .collect::>(), + })).collect::>(), + "zOrder": chart.z_order, + }), + } +} + +fn rect_to_proto(rect: Rect) -> Value { + serde_json::json!({ + "left": rect.left, + "top": rect.top, + "width": rect.width, + "height": rect.height, + "unit": "points", + }) +} + +fn stroke_to_proto(stroke: &StrokeStyle) -> Value { + serde_json::json!({ + "color": stroke.color, + "width": stroke.width, + "style": stroke.style.as_api_str(), + "unit": "points", + }) +} + +fn text_style_to_proto(style: &TextStyle) -> Value { + serde_json::json!({ + "styleName": style.style_name, + "fontSize": style.font_size, + "fontFamily": style.font_family, + "color": style.color, + "alignment": style.alignment, + "bold": style.bold, + "italic": style.italic, + "underline": style.underline, + }) +} + +fn rich_text_to_proto(text: &str, rich_text: &RichTextState) -> Value { + serde_json::json!({ + "layout": text_layout_to_proto(&rich_text.layout), + "ranges": rich_text + .ranges + .iter() + .map(|range| text_range_to_proto(text, range)) + .collect::>(), + }) +} + +fn text_range_to_proto(text: &str, range: &TextRangeAnnotation) -> Value { + serde_json::json!({ + "rangeId": range.range_id, + "anchor": format!("tr/{}", range.range_id), + "startCp": range.start_cp, + "length": range.length, + "text": text_slice_by_codepoint_range(text, range.start_cp, range.length), + "style": text_style_to_proto(&range.style), + "hyperlink": range.hyperlink.as_ref().map(HyperlinkState::to_json), + "spacingBefore": range.spacing_before, + "spacingAfter": range.spacing_after, + "lineSpacing": range.line_spacing, + }) +} + +fn text_layout_to_proto(layout: &TextLayoutState) -> Value { + serde_json::json!({ + "insets": layout.insets.map(|insets| serde_json::json!({ + "left": insets.left, + "right": insets.right, + "top": insets.top, + "bottom": insets.bottom, + "unit": "points", + })), + "wrap": layout.wrap.map(text_wrap_mode_to_proto), + "autoFit": layout.auto_fit.map(text_auto_fit_mode_to_proto), + "verticalAlignment": layout + .vertical_alignment + .map(text_vertical_alignment_to_proto), + }) +} + +fn text_wrap_mode_to_proto(mode: TextWrapMode) -> &'static str { + match mode { + TextWrapMode::Square => "square", + TextWrapMode::None => "none", + } +} + +fn text_auto_fit_mode_to_proto(mode: TextAutoFitMode) -> &'static str { + match mode { + TextAutoFitMode::None => "none", + TextAutoFitMode::ShrinkText => "shrinkText", + TextAutoFitMode::ResizeShapeToFitText => "resizeShapeToFitText", + } +} + +fn text_vertical_alignment_to_proto(alignment: TextVerticalAlignment) -> &'static str { + match alignment { + TextVerticalAlignment::Top => "top", + TextVerticalAlignment::Middle => "middle", + TextVerticalAlignment::Bottom => "bottom", + } +} + +fn placeholder_ref_to_proto(placeholder: &PlaceholderRef) -> Value { + serde_json::json!({ + "name": placeholder.name, + "placeholderType": placeholder.placeholder_type, + "index": placeholder.index, + }) +} + +fn image_payload_to_proto(payload: &ImagePayload) -> Value { + serde_json::json!({ + "format": payload.format, + "widthPx": payload.width_px, + "heightPx": payload.height_px, + "bytesBase64": BASE64_STANDARD.encode(&payload.bytes), + }) +} + +fn table_cell_to_proto(cell: &TableCellSpec) -> Value { + serde_json::json!({ + "text": cell.text, + "textStyle": text_style_to_proto(&cell.text_style), + "richText": rich_text_to_proto(&cell.text, &cell.rich_text), + "backgroundFill": cell.background_fill, + "alignment": cell.alignment, + "borders": cell.borders.as_ref().map(table_borders_to_proto), + }) +} + +fn table_style_options_to_proto(style_options: &TableStyleOptions) -> Value { + serde_json::json!({ + "headerRow": style_options.header_row, + "bandedRows": style_options.banded_rows, + "bandedColumns": style_options.banded_columns, + "firstColumn": style_options.first_column, + "lastColumn": style_options.last_column, + "totalRow": style_options.total_row, + }) +} + +fn table_borders_to_proto(borders: &TableBorders) -> Value { + serde_json::json!({ + "outside": borders.outside.as_ref().map(table_border_to_proto), + "inside": borders.inside.as_ref().map(table_border_to_proto), + "top": borders.top.as_ref().map(table_border_to_proto), + "bottom": borders.bottom.as_ref().map(table_border_to_proto), + "left": borders.left.as_ref().map(table_border_to_proto), + "right": borders.right.as_ref().map(table_border_to_proto), + }) +} + +fn table_border_to_proto(border: &TableBorder) -> Value { + serde_json::json!({ + "color": border.color, + "width": border.width, + "unit": "points", + }) +} + +fn chart_marker_to_proto(marker: &ChartMarkerStyle) -> Value { + serde_json::json!({ + "symbol": marker.symbol, + "size": marker.size, + }) +} + +fn chart_data_labels_to_proto(data_labels: &ChartDataLabels) -> Value { + serde_json::json!({ + "showValue": data_labels.show_value, + "showCategoryName": data_labels.show_category_name, + "showLeaderLines": data_labels.show_leader_lines, + "position": data_labels.position, + "textStyle": text_style_to_proto(&data_labels.text_style), + }) +} + +fn chart_legend_to_proto(legend: &ChartLegend) -> Value { + serde_json::json!({ + "position": legend.position, + "textStyle": text_style_to_proto(&legend.text_style), + }) +} + +fn chart_axis_to_proto(axis: &ChartAxisSpec) -> Value { + serde_json::json!({ + "title": axis.title, + }) +} + +fn chart_data_label_override_to_proto(override_spec: &ChartDataLabelOverride) -> Value { + serde_json::json!({ + "idx": override_spec.idx, + "text": override_spec.text, + "position": override_spec.position, + "textStyle": text_style_to_proto(&override_spec.text_style), + "fill": override_spec.fill, + "stroke": override_spec.stroke.as_ref().map(stroke_to_proto), + }) +} + +fn comment_author_to_proto(author: &CommentAuthorProfile) -> Value { + serde_json::json!({ + "displayName": author.display_name, + "initials": author.initials, + "email": author.email, + }) +} + +fn comment_thread_to_proto(thread: &CommentThread) -> Value { + serde_json::json!({ + "kind": "comment", + "threadId": thread.thread_id, + "anchor": format!("th/{}", thread.thread_id), + "target": comment_target_to_proto(&thread.target), + "position": thread.position.as_ref().map(comment_position_to_proto), + "status": comment_status_to_proto(thread.status), + "messages": thread.messages.iter().map(comment_message_to_proto).collect::>(), + }) +} + +fn comment_target_to_proto(target: &CommentTarget) -> Value { + match target { + CommentTarget::Slide { slide_id } => serde_json::json!({ + "type": "slide", + "slideId": slide_id, + "slideAnchor": format!("sl/{slide_id}"), + }), + CommentTarget::Element { + slide_id, + element_id, + } => serde_json::json!({ + "type": "element", + "slideId": slide_id, + "slideAnchor": format!("sl/{slide_id}"), + "elementId": element_id, + "elementAnchor": format!("sh/{element_id}"), + }), + CommentTarget::TextRange { + slide_id, + element_id, + start_cp, + length, + context, + } => serde_json::json!({ + "type": "textRange", + "slideId": slide_id, + "slideAnchor": format!("sl/{slide_id}"), + "elementId": element_id, + "elementAnchor": format!("sh/{element_id}"), + "startCp": start_cp, + "length": length, + "context": context, + }), + } +} + +fn comment_position_to_proto(position: &CommentPosition) -> Value { + serde_json::json!({ + "x": position.x, + "y": position.y, + "unit": "points", + }) +} + +fn comment_message_to_proto(message: &CommentMessage) -> Value { + serde_json::json!({ + "messageId": message.message_id, + "author": comment_author_to_proto(&message.author), + "text": message.text, + "createdAt": message.created_at, + "reactions": message.reactions, + }) +} + +fn comment_status_to_proto(status: CommentThreadStatus) -> &'static str { + match status { + CommentThreadStatus::Active => "active", + CommentThreadStatus::Resolved => "resolved", + } +} + +fn text_slice_by_codepoint_range(text: &str, start_cp: usize, length: usize) -> String { + text.chars().skip(start_cp).take(length).collect() +} + +fn build_table_cell( + cell: TableCellSpec, + merges: &[TableMergeRegion], + row_index: usize, + column_index: usize, +) -> TableCell { + let mut table_cell = TableCell::new(&cell.text); + if cell.text_style.bold { + table_cell = table_cell.bold(); + } + if cell.text_style.italic { + table_cell = table_cell.italic(); + } + if cell.text_style.underline { + table_cell = table_cell.underline(); + } + if let Some(color) = cell.text_style.color { + table_cell = table_cell.text_color(&color); + } + if let Some(fill) = cell.background_fill { + table_cell = table_cell.background_color(&fill); + } + if let Some(size) = cell.text_style.font_size { + table_cell = table_cell.font_size(size); + } + if let Some(font_family) = cell.text_style.font_family { + table_cell = table_cell.font_family(&font_family); + } + if let Some(alignment) = cell.alignment.or(cell.text_style.alignment) { + table_cell = match alignment { + TextAlignment::Left => table_cell.align_left(), + TextAlignment::Center => table_cell.align_center(), + TextAlignment::Right => table_cell.align_right(), + TextAlignment::Justify => table_cell.align(CellAlign::Justify), + }; + } + for merge in merges { + if row_index == merge.start_row && column_index == merge.start_column { + table_cell = table_cell + .grid_span((merge.end_column - merge.start_column + 1) as u32) + .row_span((merge.end_row - merge.start_row + 1) as u32); + } else if row_index >= merge.start_row + && row_index <= merge.end_row + && column_index >= merge.start_column + && column_index <= merge.end_column + { + if row_index == merge.start_row { + table_cell = table_cell.h_merge(); + } else { + table_cell = table_cell.v_merge(); + } + } + } + table_cell +} diff --git a/codex-rs/artifact-presentation/src/presentation_artifact/response.rs b/codex-rs/artifact-presentation/src/presentation_artifact/response.rs new file mode 100644 index 0000000000..241cce570d --- /dev/null +++ b/codex-rs/artifact-presentation/src/presentation_artifact/response.rs @@ -0,0 +1,138 @@ +#[derive(Debug, Clone, Serialize)] +pub struct PresentationArtifactResponse { + pub artifact_id: String, + pub action: String, + pub summary: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub executed_actions: Option>, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub exported_paths: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub artifact_snapshot: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub slide_list: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub layout_list: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub placeholder_list: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub theme: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub inspect_ndjson: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub resolved_record: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub proto_json: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub patch: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub active_slide_index: Option, +} + +impl PresentationArtifactResponse { + fn new( + artifact_id: String, + action: String, + summary: String, + artifact_snapshot: ArtifactSnapshot, + ) -> Self { + Self { + artifact_id, + action, + summary, + executed_actions: None, + exported_paths: Vec::new(), + artifact_snapshot: Some(artifact_snapshot), + slide_list: None, + layout_list: None, + placeholder_list: None, + theme: None, + inspect_ndjson: None, + resolved_record: None, + proto_json: None, + patch: None, + active_slide_index: None, + } + } +} + +fn response_for_document_state( + artifact_id: String, + action: String, + summary: String, + document: Option<&PresentationDocument>, +) -> PresentationArtifactResponse { + PresentationArtifactResponse { + artifact_id, + action, + summary, + executed_actions: None, + exported_paths: Vec::new(), + artifact_snapshot: document.map(snapshot_for_document), + slide_list: None, + layout_list: None, + placeholder_list: None, + theme: document.map(PresentationDocument::theme_snapshot), + inspect_ndjson: None, + resolved_record: None, + proto_json: None, + patch: None, + active_slide_index: document.and_then(|current| current.active_slide_index), + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct ArtifactSnapshot { + pub slide_count: usize, + pub slides: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SlideSnapshot { + pub slide_id: String, + pub index: usize, + pub element_ids: Vec, + pub element_types: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SlideListEntry { + pub slide_id: String, + pub index: usize, + pub is_active: bool, + pub notes: Option, + pub notes_visible: bool, + pub background_fill: Option, + pub layout_id: Option, + pub element_count: usize, +} + +#[derive(Debug, Clone, Serialize)] +pub struct LayoutListEntry { + pub layout_id: String, + pub name: String, + pub kind: String, + pub parent_layout_id: Option, + pub placeholder_count: usize, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PlaceholderListEntry { + pub scope: String, + pub source_layout_id: Option, + pub slide_index: Option, + pub element_id: Option, + pub name: String, + pub placeholder_type: String, + pub index: Option, + pub geometry: Option, + pub text_preview: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ThemeSnapshot { + pub color_scheme: HashMap, + pub hex_color_map: HashMap, + pub major_font: Option, + pub minor_font: Option, +} diff --git a/codex-rs/artifact-presentation/src/presentation_artifact/snapshot.rs b/codex-rs/artifact-presentation/src/presentation_artifact/snapshot.rs new file mode 100644 index 0000000000..8606649c38 --- /dev/null +++ b/codex-rs/artifact-presentation/src/presentation_artifact/snapshot.rs @@ -0,0 +1,339 @@ +fn cell_value_to_string(value: Value) -> String { + match value { + Value::Null => String::new(), + Value::String(text) => text, + Value::Bool(boolean) => boolean.to_string(), + Value::Number(number) => number.to_string(), + other => other.to_string(), + } +} + +fn snapshot_for_document(document: &PresentationDocument) -> ArtifactSnapshot { + ArtifactSnapshot { + slide_count: document.slides.len(), + slides: document + .slides + .iter() + .enumerate() + .map(|(index, slide)| SlideSnapshot { + slide_id: slide.slide_id.clone(), + index, + element_ids: slide + .elements + .iter() + .map(|element| element.element_id().to_string()) + .collect(), + element_types: slide + .elements + .iter() + .map(|element| element.kind().to_string()) + .collect(), + }) + .collect(), + } +} + +fn slide_list(document: &PresentationDocument) -> Vec { + document + .slides + .iter() + .enumerate() + .map(|(index, slide)| SlideListEntry { + slide_id: slide.slide_id.clone(), + index, + is_active: document.active_slide_index == Some(index), + notes: (slide.notes.visible && !slide.notes.text.is_empty()) + .then(|| slide.notes.text.clone()), + notes_visible: slide.notes.visible, + background_fill: slide.background_fill.clone(), + layout_id: slide.layout_id.clone(), + element_count: slide.elements.len(), + }) + .collect() +} + +fn layout_list(document: &PresentationDocument) -> Vec { + document + .layouts + .iter() + .map(|layout| LayoutListEntry { + layout_id: layout.layout_id.clone(), + name: layout.name.clone(), + kind: match layout.kind { + LayoutKind::Layout => "layout".to_string(), + LayoutKind::Master => "master".to_string(), + }, + parent_layout_id: layout.parent_layout_id.clone(), + placeholder_count: layout.placeholders.len(), + }) + .collect() +} + +fn points_to_emu(points: u32) -> u32 { + points.saturating_mul(POINT_TO_EMU) +} + +fn emu_to_points(emu: u32) -> u32 { + emu / POINT_TO_EMU +} + +type ImageCrop = (f64, f64, f64, f64); +type FittedImage = (u32, u32, u32, u32, Option); + +pub(crate) fn fit_image(image: &ImageElement) -> FittedImage { + let Some(payload) = image.payload.as_ref() else { + return ( + image.frame.left, + image.frame.top, + image.frame.width, + image.frame.height, + None, + ); + }; + let frame = image.frame; + let source_width = payload.width_px as f64; + let source_height = payload.height_px as f64; + let target_width = frame.width as f64; + let target_height = frame.height as f64; + let source_ratio = source_width / source_height; + let target_ratio = target_width / target_height; + + match image.fit_mode { + ImageFitMode::Stretch => (frame.left, frame.top, frame.width, frame.height, None), + ImageFitMode::Contain => { + let scale = if source_ratio > target_ratio { + target_width / source_width + } else { + target_height / source_height + }; + let width = (source_width * scale).round() as u32; + let height = (source_height * scale).round() as u32; + let left = frame.left + frame.width.saturating_sub(width) / 2; + let top = frame.top + frame.height.saturating_sub(height) / 2; + (left, top, width, height, None) + } + ImageFitMode::Cover => { + let scale = if source_ratio > target_ratio { + target_height / source_height + } else { + target_width / source_width + }; + let width = source_width * scale; + let height = source_height * scale; + let crop_x = ((width - target_width).max(0.0) / width) / 2.0; + let crop_y = ((height - target_height).max(0.0) / height) / 2.0; + ( + frame.left, + frame.top, + frame.width, + frame.height, + Some((crop_x, crop_y, crop_x, crop_y)), + ) + } + } +} + +fn normalize_image_crop( + crop: ImageCropArgs, + action: &str, +) -> Result { + for (name, value) in [ + ("left", crop.left), + ("top", crop.top), + ("right", crop.right), + ("bottom", crop.bottom), + ] { + if !(0.0..=1.0).contains(&value) { + return Err(PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("image crop `{name}` must be between 0.0 and 1.0"), + }); + } + } + Ok((crop.left, crop.top, crop.right, crop.bottom)) +} + +fn load_image_payload_from_path( + path: &Path, + action: &str, +) -> Result { + let bytes = std::fs::read(path).map_err(|error| PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("failed to read image `{}`: {error}", path.display()), + })?; + build_image_payload( + bytes, + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("image") + .to_string(), + action, + ) +} + +fn load_image_payload_from_data_url( + data_url: &str, + action: &str, +) -> Result { + let (header, payload) = + data_url + .split_once(',') + .ok_or_else(|| PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: "data_url must include a MIME header and base64 payload".to_string(), + })?; + let mime = header + .strip_prefix("data:") + .and_then(|prefix| prefix.strip_suffix(";base64")) + .ok_or_else(|| PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: "data_url must be base64-encoded".to_string(), + })?; + let bytes = BASE64_STANDARD.decode(payload).map_err(|error| { + PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("failed to decode image data_url: {error}"), + } + })?; + build_image_payload( + bytes, + format!("image.{}", image_extension_from_mime(mime)), + action, + ) +} + +fn load_image_payload_from_blob( + blob: &str, + action: &str, +) -> Result { + let bytes = BASE64_STANDARD.decode(blob.trim()).map_err(|error| { + PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("failed to decode image blob: {error}"), + } + })?; + let extension = image::guess_format(&bytes) + .ok() + .map(image_extension_from_format) + .unwrap_or("png"); + build_image_payload(bytes, format!("image.{extension}"), action) +} + +fn load_image_payload_from_uri( + uri: &str, + action: &str, +) -> Result { + let response = + reqwest::blocking::get(uri).map_err(|error| PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("failed to fetch image `{uri}`: {error}"), + })?; + let status = response.status(); + if !status.is_success() { + return Err(PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("failed to fetch image `{uri}`: HTTP {status}"), + }); + } + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(|value| value.split(';').next().unwrap_or(value).trim().to_string()); + let bytes = response + .bytes() + .map_err(|error| PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("failed to read image `{uri}`: {error}"), + })?; + build_image_payload( + bytes.to_vec(), + infer_remote_image_filename(uri, content_type.as_deref()), + action, + ) +} + +fn infer_remote_image_filename(uri: &str, content_type: Option<&str>) -> String { + let path_name = reqwest::Url::parse(uri) + .ok() + .and_then(|url| { + url.path_segments() + .and_then(Iterator::last) + .map(str::to_owned) + }) + .filter(|segment| !segment.is_empty()); + match (path_name, content_type) { + (Some(path_name), _) if Path::new(&path_name).extension().is_some() => path_name, + (Some(path_name), Some(content_type)) => { + format!("{path_name}.{}", image_extension_from_mime(content_type)) + } + (Some(path_name), None) => path_name, + (None, Some(content_type)) => format!("image.{}", image_extension_from_mime(content_type)), + (None, None) => "image.png".to_string(), + } +} + +fn build_image_payload( + bytes: Vec, + filename: String, + action: &str, +) -> Result { + let image = image::load_from_memory(&bytes).map_err(|error| { + PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("failed to decode image bytes: {error}"), + } + })?; + let (width_px, height_px) = image.dimensions(); + let format = Path::new(&filename) + .extension() + .and_then(|extension| extension.to_str()) + .unwrap_or("png") + .to_uppercase(); + Ok(ImagePayload { + bytes, + format, + width_px, + height_px, + }) +} + +fn image_extension_from_mime(mime: &str) -> &'static str { + match mime { + "image/jpeg" => "jpg", + "image/gif" => "gif", + "image/webp" => "webp", + _ => "png", + } +} + +fn image_extension_from_format(format: image::ImageFormat) -> &'static str { + match format { + image::ImageFormat::Jpeg => "jpg", + image::ImageFormat::Gif => "gif", + image::ImageFormat::WebP => "webp", + image::ImageFormat::Bmp => "bmp", + image::ImageFormat::Tiff => "tiff", + _ => "png", + } +} + +fn index_out_of_range(action: &str, index: usize, len: usize) -> PresentationArtifactError { + PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("slide index {index} is out of range for {len} slides"), + } +} + +fn to_index(value: u32) -> Result { + usize::try_from(value).map_err(|_| PresentationArtifactError::InvalidArgs { + action: "insert_slide".to_string(), + message: "index does not fit in usize".to_string(), + }) +} + +fn resequence_z_order(slide: &mut PresentationSlide) { + for (index, element) in slide.elements.iter_mut().enumerate() { + element.set_z_order(index); + } +} diff --git a/codex-rs/artifact-presentation/src/tests.rs b/codex-rs/artifact-presentation/src/tests.rs new file mode 100644 index 0000000000..2ced6ad642 --- /dev/null +++ b/codex-rs/artifact-presentation/src/tests.rs @@ -0,0 +1,3512 @@ +use super::presentation_artifact::*; +use base64::Engine; +use pretty_assertions::assert_eq; +use std::io::Read; + +fn zip_entry_text( + path: &std::path::Path, + entry_name: &str, +) -> Result> { + let file = std::fs::File::open(path)?; + let mut archive = zip::ZipArchive::new(file)?; + let mut entry = archive.by_name(entry_name)?; + let mut text = String::new(); + entry.read_to_string(&mut text)?; + Ok(text) +} + +fn zip_entry_names(path: &std::path::Path) -> Result, Box> { + let file = std::fs::File::open(path)?; + let archive = zip::ZipArchive::new(file)?; + Ok(archive.file_names().map(str::to_owned).collect()) +} + +fn parse_ndjson_lines(ndjson: &str) -> Result, serde_json::Error> { + ndjson + .lines() + .filter(|line| !line.is_empty()) + .map(serde_json::from_str) + .collect() +} + +#[test] +fn manager_can_create_add_text_and_export() -> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = PresentationArtifactManager::default(); + let create_response = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Demo" }), + }, + temp_dir.path(), + )?; + let artifact_id = create_response.artifact_id; + + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + + let add_text = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_text_shape".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "text": "hello", + "position": { "left": 40, "top": 40, "width": 200, "height": 80 } + }), + }, + temp_dir.path(), + )?; + assert_eq!( + add_text + .artifact_snapshot + .as_ref() + .map(|snapshot| snapshot.slide_count), + Some(1) + ); + + let export_path = temp_dir.path().join("deck.pptx"); + let export = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id), + action: "export_pptx".to_string(), + args: serde_json::json!({ "path": export_path }), + }, + temp_dir.path(), + )?; + assert_eq!(export.exported_paths.len(), 1); + assert!(export.exported_paths[0].exists()); + Ok(()) +} + +#[test] +fn manager_can_import_exported_presentation() -> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Roundtrip" }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id.clone(); + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id), + action: "add_shape".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "geometry": "rectangle", + "position": { "left": 24, "top": 24, "width": 180, "height": 120 }, + "text": "shape" + }), + }, + temp_dir.path(), + )?; + let export_path = temp_dir.path().join("roundtrip.pptx"); + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(created.artifact_id), + action: "export_pptx".to_string(), + args: serde_json::json!({ "path": export_path }), + }, + temp_dir.path(), + )?; + + let imported = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "import_pptx".to_string(), + args: serde_json::json!({ "path": "roundtrip.pptx" }), + }, + temp_dir.path(), + )?; + assert_eq!( + imported + .artifact_snapshot + .as_ref() + .map(|snapshot| snapshot.slide_count), + Some(1) + ); + Ok(()) +} + +#[test] +fn custom_slide_size_is_written_to_exported_pptx() -> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ + "name": "Custom Size", + "slide_size": { "width": 960, "height": 540 } + }), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(created.artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + let export_path = temp_dir.path().join("custom-size.pptx"); + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(created.artifact_id), + action: "export_pptx".to_string(), + args: serde_json::json!({ "path": export_path }), + }, + temp_dir.path(), + )?; + + let presentation_xml = zip_entry_text( + &temp_dir.path().join("custom-size.pptx"), + "ppt/presentation.xml", + )?; + assert!(presentation_xml.contains(r#"cx="12192000" cy="6858000""#)); + assert!(presentation_xml.contains(r#"p:notesSz cx="6858000" cy="12192000""#)); + Ok(()) +} + +#[test] +fn exported_images_are_real_pictures_with_media_parts() -> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let source_path = temp_dir.path().join("source.png"); + image::RgbaImage::from_pixel(24, 16, image::Rgba([0x20, 0x90, 0xD0, 0xFF])) + .save(&source_path)?; + + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Image Export" }), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(created.artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(created.artifact_id.clone()), + action: "add_image".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "path": "source.png", + "position": { "left": 36, "top": 48, "width": 144, "height": 96 }, + "rotation": 15, + "flip_horizontal": true, + "alt": "Company logo" + }), + }, + temp_dir.path(), + )?; + + let export_path = temp_dir.path().join("images.pptx"); + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(created.artifact_id), + action: "export_pptx".to_string(), + args: serde_json::json!({ "path": export_path }), + }, + temp_dir.path(), + )?; + + let pptx_path = temp_dir.path().join("images.pptx"); + let slide_xml = zip_entry_text(&pptx_path, "ppt/slides/slide1.xml")?; + let rels_xml = zip_entry_text(&pptx_path, "ppt/slides/_rels/slide1.xml.rels")?; + let content_types_xml = zip_entry_text(&pptx_path, "[Content_Types].xml")?; + let entry_names = zip_entry_names(&pptx_path)?; + + assert!(slide_xml.contains("")); + assert!(slide_xml.contains(r#"descr="Company logo""#)); + assert!(slide_xml.contains(r#"r:embed="rIdImage1""#)); + assert!(slide_xml.contains(r#""#)); + assert!(!slide_xml.contains("Image Placeholder:")); + assert!(rels_xml.contains("relationships/image")); + assert!(rels_xml.contains(r#"Target="../media/image1.png""#)); + assert!(content_types_xml.contains(r#"Extension="png" ContentType="image/png""#)); + assert!(entry_names.contains(&"ppt/media/image1.png".to_string())); + Ok(()) +} + +#[test] +fn tool_request_accepts_sequential_actions() -> Result<(), Box> { + let request: PresentationArtifactToolRequest = serde_json::from_value(serde_json::json!({ + "actions": [ + { + "action": "create", + "args": { "name": "Batch Deck" } + }, + { + "action": "export_pptx", + "args": { "path": "deck.pptx" } + } + ] + }))?; + + let execution = request.into_execution_request()?; + assert_eq!(execution.artifact_id, None); + assert_eq!(execution.requests.len(), 2); + assert_eq!(execution.requests[0].action, "create"); + assert_eq!(execution.requests[1].action, "export_pptx"); + Ok(()) +} + +#[test] +fn manager_can_execute_sequential_actions() -> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = PresentationArtifactManager::default(); + let response = manager.execute_requests( + PresentationArtifactExecutionRequest { + artifact_id: None, + requests: vec![ + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Batch Deck" }), + }, + PresentationArtifactRequest { + artifact_id: None, + action: "add_slide".to_string(), + args: serde_json::json!({}), + }, + PresentationArtifactRequest { + artifact_id: None, + action: "add_text_shape".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "text": "hello", + "position": { "left": 40, "top": 40, "width": 200, "height": 80 } + }), + }, + ], + }, + temp_dir.path(), + )?; + + assert_eq!(response.action, "batch"); + assert_eq!( + response.executed_actions, + Some(vec![ + "create".to_string(), + "add_slide".to_string(), + "add_text_shape".to_string(), + ]) + ); + assert_eq!( + response + .artifact_snapshot + .as_ref() + .map(|snapshot| snapshot.slide_count), + Some(1) + ); + assert!( + response + .summary + .contains("Executed 3 actions sequentially.") + ); + Ok(()) +} + +#[test] +fn imported_pptx_surfaces_image_elements() -> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let source_path = temp_dir.path().join("import-source.png"); + image::RgbaImage::from_pixel(20, 20, image::Rgba([0xD0, 0x60, 0x20, 0xFF])) + .save(&source_path)?; + + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Image Import" }), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(created.artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(created.artifact_id.clone()), + action: "add_image".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "path": "import-source.png", + "position": { "left": 40, "top": 52, "width": 120, "height": 120 }, + "crop": { "left": 0.1, "top": 0.0, "right": 0.05, "bottom": 0.0 }, + "rotation": -10, + "flip_horizontal": true, + "flip_vertical": true, + "lock_aspect_ratio": true, + "alt": "Imported logo" + }), + }, + temp_dir.path(), + )?; + let export_path = temp_dir.path().join("image-import-roundtrip.pptx"); + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(created.artifact_id), + action: "export_pptx".to_string(), + args: serde_json::json!({ "path": export_path }), + }, + temp_dir.path(), + )?; + + let imported = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "import_pptx".to_string(), + args: serde_json::json!({ "path": "image-import-roundtrip.pptx" }), + }, + temp_dir.path(), + )?; + assert_eq!( + imported + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .map(|slide| slide.element_types.clone()), + Some(vec!["image".to_string()]) + ); + let image_anchor = imported + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .and_then(|slide| slide.element_ids.first()) + .map(|id| format!("im/{id}")) + .expect("image anchor"); + let resolved = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(imported.artifact_id), + action: "resolve".to_string(), + args: serde_json::json!({ "id": image_anchor }), + }, + temp_dir.path(), + )?; + assert_eq!( + resolved + .resolved_record + .as_ref() + .and_then(|record| record.get("alt")) + .and_then(serde_json::Value::as_str), + Some("Imported logo") + ); + assert_eq!( + resolved + .resolved_record + .as_ref() + .and_then(|record| record.get("rotation")) + .and_then(serde_json::Value::as_i64), + Some(-10) + ); + assert_eq!( + resolved + .resolved_record + .as_ref() + .and_then(|record| record.get("flipHorizontal")) + .and_then(serde_json::Value::as_bool), + Some(true) + ); + assert_eq!( + resolved + .resolved_record + .as_ref() + .and_then(|record| record.get("flipVertical")) + .and_then(serde_json::Value::as_bool), + Some(true) + ); + assert_eq!( + resolved + .resolved_record + .as_ref() + .and_then(|record| record.get("lockAspectRatio")) + .and_then(serde_json::Value::as_bool), + Some(true) + ); + assert_eq!( + resolved + .resolved_record + .as_ref() + .and_then(|record| record.get("crop")) + .and_then(|crop| crop.get("left")) + .and_then(serde_json::Value::as_f64), + Some(0.1) + ); + Ok(()) +} + +#[test] +fn image_fit_contain_preserves_aspect_ratio() { + let image = ImageElement { + element_id: "element_1".to_string(), + frame: Rect { + left: 10, + top: 10, + width: 200, + height: 200, + }, + payload: Some(ImagePayload { + bytes: Vec::new(), + format: "PNG".to_string(), + width_px: 400, + height_px: 200, + }), + fit_mode: ImageFitMode::Contain, + crop: None, + rotation_degrees: None, + flip_horizontal: false, + flip_vertical: false, + lock_aspect_ratio: true, + alt_text: None, + prompt: None, + is_placeholder: false, + placeholder: None, + z_order: 0, + }; + + let (left, top, width, height, crop) = fit_image(&image); + assert_eq!((left, top, width, height), (10, 60, 200, 100)); + assert_eq!(crop, None); +} + +#[test] +fn preview_image_writer_supports_jpeg_scale_and_svg() -> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let source_path = temp_dir.path().join("preview.png"); + image::RgbaImage::from_pixel(80, 40, image::Rgba([0x22, 0x66, 0xAA, 0xFF])) + .save(&source_path)?; + let target_path = temp_dir.path().join("preview.jpg"); + write_preview_image( + &source_path, + &target_path, + PreviewOutputFormat::Jpeg, + 0.5, + 82, + "test", + )?; + let rendered = image::open(&target_path)?; + assert_eq!((rendered.width(), rendered.height()), (40, 20)); + assert_eq!( + image::ImageFormat::from_path(&target_path)?, + image::ImageFormat::Jpeg + ); + assert!(!source_path.exists()); + + let svg_source_path = temp_dir.path().join("preview-svg.png"); + image::RgbaImage::from_pixel(32, 16, image::Rgba([0x55, 0xAA, 0x44, 0xFF])) + .save(&svg_source_path)?; + let svg_target_path = temp_dir.path().join("preview.svg"); + write_preview_image( + &svg_source_path, + &svg_target_path, + PreviewOutputFormat::Svg, + 0.5, + 90, + "test", + )?; + let svg = std::fs::read_to_string(&svg_target_path)?; + assert!(svg.contains(r#" Result<(), Box> { + let mut image_bytes = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 16, + 8, + image::Rgba([0x11, 0x88, 0xCC, 0xFF]), + )) + .write_to(&mut image_bytes, image::ImageFormat::Png)?; + let png = image_bytes.into_inner(); + + let server = tiny_http::Server::http("127.0.0.1:0").expect("server"); + let port = server.server_addr().to_ip().expect("ip addr").port(); + let server_thread = std::thread::spawn(move || { + for request in server.incoming_requests().take(2) { + let response = tiny_http::Response::from_data(png.clone()).with_header( + tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"image/png"[..]) + .expect("header"), + ); + request.respond(response).expect("respond"); + } + }); + + let temp_dir = tempfile::tempdir()?; + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Remote Images" }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + let remote_uri = format!("http://127.0.0.1:{port}/image.png"); + let added = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_image".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "uri": remote_uri, + "position": { "left": 32, "top": 48, "width": 120, "height": 60 } + }), + }, + temp_dir.path(), + )?; + let element_id = added + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .and_then(|slide| slide.element_ids.last()) + .cloned() + .expect("image id"); + assert_eq!( + added + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .map(|slide| slide.element_types.clone()), + Some(vec!["image".to_string()]) + ); + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "replace_image".to_string(), + args: serde_json::json!({ + "element_id": format!("im/{element_id}"), + "uri": format!("http://127.0.0.1:{port}/updated.png"), + "fit": "contain" + }), + }, + temp_dir.path(), + )?; + let inspect = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id), + action: "inspect".to_string(), + args: serde_json::json!({ "kind": "image" }), + }, + temp_dir.path(), + )?; + assert!( + inspect + .inspect_ndjson + .expect("image inspect") + .contains("\"fit\":\"Contain\"") + ); + server_thread.join().expect("server thread"); + Ok(()) +} + +#[test] +fn image_blobs_can_add_images() -> Result<(), Box> { + let mut image_bytes = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 10, + 6, + image::Rgba([0xAA, 0x55, 0x22, 0xFF]), + )) + .write_to(&mut image_bytes, image::ImageFormat::Png)?; + let blob = base64::engine::general_purpose::STANDARD.encode(image_bytes.into_inner()); + + let temp_dir = tempfile::tempdir()?; + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Blob Images" }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + let added = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_image".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "blob": blob, + "position": { "left": 32, "top": 48, "width": 100, "height": 60 } + }), + }, + temp_dir.path(), + )?; + let image_id = added + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .and_then(|slide| slide.element_ids.first()) + .cloned() + .expect("image id"); + let proto = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id), + action: "to_proto".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + assert_eq!( + proto + .proto_json + .as_ref() + .and_then(|proto| proto.get("slides")) + .and_then(serde_json::Value::as_array) + .and_then(|slides| slides.first()) + .and_then(|slide| slide.get("elements")) + .and_then(serde_json::Value::as_array) + .and_then(|elements| elements.iter().find(|element| { + element.get("elementId").and_then(serde_json::Value::as_str) + == Some(image_id.as_str()) + })) + .and_then(|record| record.get("payload")) + .and_then(|payload| payload.get("format")) + .and_then(serde_json::Value::as_str), + Some("PNG") + ); + Ok(()) +} + +#[test] +fn active_slide_can_be_set_and_tracks_reorders() -> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Active Slide" }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id; + for _ in 0..3 { + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + } + let set_active = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "set_active_slide".to_string(), + args: serde_json::json!({ "slide_index": 2 }), + }, + temp_dir.path(), + )?; + assert_eq!(set_active.active_slide_index, Some(2)); + assert_eq!( + set_active.slide_list.as_ref().map(|slides| slides + .iter() + .map(|slide| slide.is_active) + .collect::>()), + Some(vec![false, false, true]) + ); + + let moved = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "move_slide".to_string(), + args: serde_json::json!({ "from_index": 2, "to_index": 0 }), + }, + temp_dir.path(), + )?; + let summary = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "get_summary".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + assert_eq!(summary.active_slide_index, Some(0)); + assert_eq!( + summary.slide_list.as_ref().map(|slides| slides + .iter() + .map(|slide| slide.is_active) + .collect::>()), + Some(vec![true, false, false]) + ); + + let inspect = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "inspect".to_string(), + args: serde_json::json!({ "kind": "deck,slide" }), + }, + temp_dir.path(), + )?; + let inspect_ndjson = inspect.inspect_ndjson.expect("inspect"); + assert!(inspect_ndjson.contains("\"activeSlideIndex\":0")); + assert!(inspect_ndjson.contains("\"isActive\":true")); + + let active_slide_id = moved + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .map(|slide| slide.slide_id.clone()) + .expect("active slide id"); + let resolved = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "resolve".to_string(), + args: serde_json::json!({ "id": format!("sl/{active_slide_id}") }), + }, + temp_dir.path(), + )?; + assert_eq!( + resolved + .resolved_record + .as_ref() + .and_then(|record| record.get("isActive")) + .and_then(serde_json::Value::as_bool), + Some(true) + ); + + let deleted = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id), + action: "delete_slide".to_string(), + args: serde_json::json!({ "slide_index": 0 }), + }, + temp_dir.path(), + )?; + assert_eq!( + deleted + .artifact_snapshot + .as_ref() + .map(|snapshot| snapshot.slide_count), + Some(2) + ); + let after_delete = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(deleted.artifact_id), + action: "list_slides".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + assert_eq!(after_delete.active_slide_index, Some(0)); + assert_eq!( + after_delete.slide_list.as_ref().map(|slides| slides + .iter() + .map(|slide| slide.is_active) + .collect::>()), + Some(vec![true, false]) + ); + Ok(()) +} + +#[test] +fn text_replace_and_insert_helpers_update_text_elements() -> Result<(), Box> +{ + let temp_dir = tempfile::tempdir()?; + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Text Helpers" }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + let added = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_text_shape".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "text": "Revenue up 24%", + "position": { "left": 24, "top": 24, "width": 240, "height": 80 } + }), + }, + temp_dir.path(), + )?; + let element_id = added + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .and_then(|slide| slide.element_ids.first()) + .cloned() + .expect("text id"); + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "replace_text".to_string(), + args: serde_json::json!({ + "element_id": format!("sh/{element_id}"), + "search": "24%", + "replace": "31%" + }), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "insert_text_after".to_string(), + args: serde_json::json!({ + "element_id": format!("sh/{element_id}"), + "after": "Revenue", + "insert": " QoQ" + }), + }, + temp_dir.path(), + )?; + let resolved = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id), + action: "resolve".to_string(), + args: serde_json::json!({ "id": format!("sh/{element_id}") }), + }, + temp_dir.path(), + )?; + assert_eq!( + resolved + .resolved_record + .as_ref() + .and_then(|record| record.get("text")) + .and_then(serde_json::Value::as_str), + Some("Revenue QoQ up 31%") + ); + Ok(()) +} + +#[test] +fn hyperlinks_are_inspectable_and_exported() -> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Hyperlinks" }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id; + for _ in 0..2 { + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + } + + let text = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_text_shape".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "text": "Open roadmap", + "position": { "left": 24, "top": 24, "width": 220, "height": 60 } + }), + }, + temp_dir.path(), + )?; + let text_id = text + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .and_then(|slide| slide.element_ids.first()) + .cloned() + .expect("text id"); + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "set_hyperlink".to_string(), + args: serde_json::json!({ + "element_id": format!("sh/{text_id}"), + "link_type": "url", + "url": "https://example.com/roadmap", + "tooltip": "Roadmap", + "highlight_click": false + }), + }, + temp_dir.path(), + )?; + + let shape = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_shape".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "geometry": "rounded_rectangle", + "position": { "left": 24, "top": 120, "width": 220, "height": 72 }, + "text": "Jump to appendix" + }), + }, + temp_dir.path(), + )?; + let shape_id = shape + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .and_then(|slide| slide.element_ids.last()) + .cloned() + .expect("shape id"); + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "set_hyperlink".to_string(), + args: serde_json::json!({ + "element_id": format!("sh/{shape_id}"), + "link_type": "slide", + "slide_index": 1, + "tooltip": "Appendix" + }), + }, + temp_dir.path(), + )?; + + let inspect = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "inspect".to_string(), + args: serde_json::json!({ "kind": "textbox,shape" }), + }, + temp_dir.path(), + )?; + let inspect_ndjson = inspect.inspect_ndjson.expect("inspect"); + assert!(inspect_ndjson.contains("\"type\":\"url\"")); + assert!(inspect_ndjson.contains("\"url\":\"https://example.com/roadmap\"")); + assert!(inspect_ndjson.contains("\"type\":\"slide\"")); + assert!(inspect_ndjson.contains("\"slideIndex\":1")); + + let resolved = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "resolve".to_string(), + args: serde_json::json!({ "id": format!("sh/{text_id}") }), + }, + temp_dir.path(), + )?; + assert_eq!( + resolved + .resolved_record + .as_ref() + .and_then(|record| record.get("hyperlink")) + .and_then(|hyperlink| hyperlink.get("url")) + .and_then(serde_json::Value::as_str), + Some("https://example.com/roadmap") + ); + + let export_path = temp_dir.path().join("hyperlinks.pptx"); + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "export_pptx".to_string(), + args: serde_json::json!({ "path": export_path }), + }, + temp_dir.path(), + )?; + let slide_xml = zip_entry_text( + &temp_dir.path().join("hyperlinks.pptx"), + "ppt/slides/slide1.xml", + )?; + let rels_xml = zip_entry_text( + &temp_dir.path().join("hyperlinks.pptx"), + "ppt/slides/_rels/slide1.xml.rels", + )?; + assert!(slide_xml.contains("hlinkClick")); + assert!(rels_xml.contains("https://example.com/roadmap")); + assert!(rels_xml.contains("slide2.xml")); + + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "set_hyperlink".to_string(), + args: serde_json::json!({ + "element_id": format!("sh/{text_id}"), + "clear": true + }), + }, + temp_dir.path(), + )?; + let cleared = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id), + action: "resolve".to_string(), + args: serde_json::json!({ "id": format!("sh/{text_id}") }), + }, + temp_dir.path(), + )?; + assert_eq!( + cleared + .resolved_record + .as_ref() + .and_then(|record| record.get("hyperlink")), + None + ); + Ok(()) +} + +#[test] +fn manager_supports_layout_theme_notes_and_inspect() -> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ + "name": "Deck", + "theme": { + "color_scheme": { + "accent1": "#123456", + "bg1": "#FFFFFF", + "tx1": "#111111" + }, + "major_font": "Aptos" + } + }), + }, + temp_dir.path(), + )?; + assert_eq!( + created + .theme + .as_ref() + .map(|theme| theme.hex_color_map.clone()), + Some( + [ + ("accent1".to_string(), "123456".to_string()), + ("bg1".to_string(), "FFFFFF".to_string()), + ("tx1".to_string(), "111111".to_string()), + ] + .into_iter() + .collect() + ) + ); + let artifact_id = created.artifact_id.clone(); + + let master_layouts = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "create_layout".to_string(), + args: serde_json::json!({ "name": "Brand Master", "kind": "master" }), + }, + temp_dir.path(), + )?; + assert_eq!(master_layouts.layout_list.as_ref().map(Vec::len), Some(1)); + let master_id = master_layouts.layout_list.unwrap()[0].layout_id.clone(); + + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_layout_placeholder".to_string(), + args: serde_json::json!({ + "layout_id": master_id, + "name": "title", + "placeholder_type": "title", + "text": "Placeholder title", + "position": { "left": 48, "top": 48, "width": 500, "height": 60 } + }), + }, + temp_dir.path(), + )?; + + let child_layouts = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "create_layout".to_string(), + args: serde_json::json!({ + "name": "Title Slide", + "kind": "layout", + "parent_layout_id": master_id + }), + }, + temp_dir.path(), + )?; + assert_eq!(child_layouts.layout_list.as_ref().map(Vec::len), Some(2)); + let layout_id = child_layouts + .layout_list + .as_ref() + .and_then(|layouts| layouts.iter().find(|layout| layout.kind == "layout")) + .map(|layout| layout.layout_id.clone()) + .expect("child layout id"); + + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_layout_placeholder".to_string(), + args: serde_json::json!({ + "layout_id": "Title Slide", + "name": "subtitle", + "placeholder_type": "subtitle", + "text": "Placeholder subtitle", + "position": { "left": 48, "top": 128, "width": 500, "height": 48 } + }), + }, + temp_dir.path(), + )?; + + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_layout_placeholder".to_string(), + args: serde_json::json!({ + "layout_id": "title slide", + "name": "hero-image", + "placeholder_type": "picture", + "text": "Add cover image", + "position": { "left": 420, "top": 40, "width": 180, "height": 120 } + }), + }, + temp_dir.path(), + )?; + + let added_slide = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({ "layout": layout_id }), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "set_notes".to_string(), + args: serde_json::json!({ "slide_index": 0, "text": "Speaker notes" }), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "append_notes".to_string(), + args: serde_json::json!({ "slide_index": 0, "text": "More context" }), + }, + temp_dir.path(), + )?; + let layout_placeholders = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "list_layout_placeholders".to_string(), + args: serde_json::json!({ "layout_id": layout_id }), + }, + temp_dir.path(), + )?; + assert_eq!( + layout_placeholders.placeholder_list.as_ref().map(Vec::len), + Some(3) + ); + + let slide_placeholders = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "list_slide_placeholders".to_string(), + args: serde_json::json!({ "slide_index": 0 }), + }, + temp_dir.path(), + )?; + assert_eq!( + slide_placeholders.placeholder_list.as_ref().map(Vec::len), + Some(3) + ); + assert_eq!( + slide_placeholders + .placeholder_list + .as_ref() + .and_then(|placeholders| placeholders + .iter() + .find(|placeholder| placeholder.placeholder_type == "picture") + .map(|placeholder| ( + placeholder.geometry.clone(), + placeholder.text_preview.clone() + ))), + Some(( + Some("Image".to_string()), + Some("Add cover image".to_string()) + )) + ); + let image_placeholder_id = added_slide + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .and_then(|slide| slide.element_ids.last()) + .cloned() + .expect("image placeholder id"); + + let resolved_layout = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "resolve".to_string(), + args: serde_json::json!({ "id": format!("ly/{layout_id}") }), + }, + temp_dir.path(), + )?; + assert_eq!( + resolved_layout + .resolved_record + .as_ref() + .and_then(|record| record.get("kind")) + .and_then(serde_json::Value::as_str), + Some("layout") + ); + + let inspect = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "inspect".to_string(), + args: serde_json::json!({ "kind": "deck,slide,textbox,image,notes,layoutList" }), + }, + temp_dir.path(), + )?; + let inspect_ndjson = inspect.inspect_ndjson.expect("inspect output"); + assert!(inspect_ndjson.contains("\"kind\":\"layout\"")); + assert!(inspect_ndjson.contains("\"kind\":\"notes\"")); + assert!(inspect_ndjson.contains("\"placeholder\":\"title\"")); + assert!(inspect_ndjson.contains("\"placeholder\":\"picture\"")); + assert!(inspect_ndjson.contains("\"kind\":\"image\"")); + + let resolved_image_placeholder = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "resolve".to_string(), + args: serde_json::json!({ "id": format!("im/{image_placeholder_id}") }), + }, + temp_dir.path(), + )?; + assert_eq!( + resolved_image_placeholder + .resolved_record + .as_ref() + .and_then(|record| record.get("isPlaceholder")) + .and_then(serde_json::Value::as_bool), + Some(true) + ); + assert_eq!( + resolved_image_placeholder + .resolved_record + .as_ref() + .and_then(|record| record.get("placeholder")) + .and_then(serde_json::Value::as_str), + Some("picture") + ); + + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "set_slide_layout".to_string(), + args: serde_json::json!({ "slide_index": 1, "layout_id": "title slide" }), + }, + temp_dir.path(), + )?; + let inherited_placeholders = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id), + action: "list_slide_placeholders".to_string(), + args: serde_json::json!({ "slide_index": 1 }), + }, + temp_dir.path(), + )?; + assert_eq!( + inherited_placeholders + .placeholder_list + .as_ref() + .map(Vec::len), + Some(3) + ); + assert_eq!( + inherited_placeholders + .placeholder_list + .as_ref() + .and_then(|placeholders| placeholders + .iter() + .find(|placeholder| placeholder.placeholder_type == "title") + .map(|placeholder| placeholder.name.clone())), + Some("title".to_string()) + ); + assert_eq!( + inherited_placeholders + .placeholder_list + .as_ref() + .and_then(|placeholders| placeholders + .iter() + .find(|placeholder| placeholder.placeholder_type == "picture") + .map(|placeholder| placeholder.geometry.clone())), + Some(Some("Image".to_string())) + ); + + let truncated = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(created.artifact_id), + action: "inspect".to_string(), + args: serde_json::json!({ + "kind": "deck,slide,textbox,image,notes,layoutList", + "max_chars": 250 + }), + }, + temp_dir.path(), + )?; + assert!( + truncated + .inspect_ndjson + .expect("truncated inspect") + .contains("\"noticeType\":\"truncation\"") + ); + Ok(()) +} + +#[test] +fn named_text_styles_apply_to_text_and_tables() -> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ + "name": "Styles", + "theme": { + "color_scheme": { + "tx1": "#222222" + }, + "major_font": "Aptos Display", + "minor_font": "Aptos" + } + }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id; + let described = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "describe_styles".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + assert_eq!( + described + .resolved_record + .as_ref() + .and_then(|record| record.get("styles")) + .and_then(serde_json::Value::as_array) + .map(Vec::len), + Some(5) + ); + let title_style = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "get_style".to_string(), + args: serde_json::json!({ "name": "title" }), + }, + temp_dir.path(), + )?; + assert_eq!( + title_style.resolved_record, + Some(serde_json::json!({ + "kind": "textStyle", + "id": "st/title", + "name": "title", + "builtIn": true, + "style": { + "styleName": "title", + "fontSize": 28, + "fontFamily": "Aptos Display", + "color": "222222", + "alignment": "left", + "bold": true, + "italic": false, + "underline": false + } + })) + ); + let custom_style = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_style".to_string(), + args: serde_json::json!({ + "name": "callout", + "font_size": 18, + "color": "#336699", + "italic": true, + "underline": true + }), + }, + temp_dir.path(), + )?; + assert_eq!( + custom_style.resolved_record, + Some(serde_json::json!({ + "kind": "textStyle", + "id": "st/callout", + "name": "callout", + "builtIn": false, + "style": { + "styleName": "callout", + "fontSize": 18, + "fontFamily": serde_json::Value::Null, + "color": "336699", + "alignment": serde_json::Value::Null, + "bold": false, + "italic": true, + "underline": true + } + })) + ); + + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + let text_added = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_text_shape".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "text": "Styled title", + "position": { "left": 40, "top": 40, "width": 220, "height": 50 }, + "style": "title", + "underline": true + }), + }, + temp_dir.path(), + )?; + let text_id = text_added + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .and_then(|slide| slide.element_ids.first()) + .cloned() + .expect("text id"); + let table_added = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_table".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "position": { "left": 40, "top": 120, "width": 180, "height": 80 }, + "rows": [["A"]], + }), + }, + temp_dir.path(), + )?; + let table_id = table_added + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .and_then(|slide| slide.element_ids.last()) + .cloned() + .expect("table id"); + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "update_table_cell".to_string(), + args: serde_json::json!({ + "element_id": table_id, + "row": 0, + "column": 0, + "value": "Styled cell", + "styling": { + "style": "callout" + } + }), + }, + temp_dir.path(), + )?; + + let resolved_text = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "resolve".to_string(), + args: serde_json::json!({ "id": format!("sh/{text_id}") }), + }, + temp_dir.path(), + )?; + assert_eq!( + resolved_text + .resolved_record + .as_ref() + .and_then(|record| record.get("textStyle")) + .cloned(), + Some(serde_json::json!({ + "styleName": "title", + "fontSize": 28, + "fontFamily": "Aptos Display", + "color": "222222", + "alignment": "left", + "bold": true, + "italic": false, + "underline": true + })) + ); + let resolved_table = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id), + action: "resolve".to_string(), + args: serde_json::json!({ "id": format!("tb/{table_id}") }), + }, + temp_dir.path(), + )?; + assert_eq!( + resolved_table + .resolved_record + .as_ref() + .and_then(|record| record.get("cellTextStyles")) + .cloned(), + Some(serde_json::json!([ + [ + { + "styleName": "callout", + "fontSize": 18, + "fontFamily": serde_json::Value::Null, + "color": "336699", + "alignment": serde_json::Value::Null, + "bold": false, + "italic": true, + "underline": true + } + ] + ])) + ); + Ok(()) +} + +#[test] +fn layout_names_resolve_in_slide_actions_and_insert_defaults_after_active_slide() +-> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Layouts by Name" }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id; + + let layout_created = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "create_layout".to_string(), + args: serde_json::json!({ "name": "Title Slide" }), + }, + temp_dir.path(), + )?; + let layout_id = layout_created + .layout_list + .as_ref() + .and_then(|layouts| layouts.first()) + .map(|layout| layout.layout_id.clone()) + .expect("layout id"); + + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_layout_placeholder".to_string(), + args: serde_json::json!({ + "layout_id": layout_id, + "name": "title", + "placeholder_type": "title", + "text": "Placeholder title" + }), + }, + temp_dir.path(), + )?; + + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({ "layout": "Title Slide" }), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "set_active_slide".to_string(), + args: serde_json::json!({ "slide_index": 0 }), + }, + temp_dir.path(), + )?; + + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "insert_slide".to_string(), + args: serde_json::json!({ "layout": "title slide" }), + }, + temp_dir.path(), + )?; + let inserted = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "list_slides".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + assert_eq!( + inserted.slide_list.as_ref().map(|slides| slides + .iter() + .map(|slide| slide.layout_id.clone()) + .collect::>()), + Some(vec![ + Some("layout_1".to_string()), + Some("layout_1".to_string()), + None + ]) + ); + assert_eq!( + inserted.slide_list.as_ref().map(|slides| slides + .iter() + .map(|slide| slide.is_active) + .collect::>()), + Some(vec![true, false, false]) + ); + + let placeholders = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id), + action: "list_layout_placeholders".to_string(), + args: serde_json::json!({ "layout_id": "TITLE SLIDE" }), + }, + temp_dir.path(), + )?; + assert_eq!( + placeholders.placeholder_list.as_ref().map(|entries| entries + .iter() + .map(|entry| entry.name.clone()) + .collect::>()), + Some(vec!["title".to_string()]) + ); + + let child_layout = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(placeholders.artifact_id), + action: "create_layout".to_string(), + args: serde_json::json!({ + "name": "Child Layout", + "parent_layout_id": "title slide" + }), + }, + temp_dir.path(), + )?; + assert_eq!( + child_layout.layout_list.as_ref().map(|layouts| layouts + .iter() + .find(|layout| layout.name == "Child Layout") + .and_then(|layout| layout.parent_layout_id.clone())), + Some(Some("layout_1".to_string())) + ); + Ok(()) +} + +#[test] +fn inspect_supports_filters_target_windows_and_shape_text_metadata() +-> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Inspect Filters" }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + + let first_text = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_text_shape".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "text": "First KPI", + "position": { "left": 10, "top": 10, "width": 120, "height": 40 } + }), + }, + temp_dir.path(), + )?; + let second_text = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_text_shape".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "text": "Second KPI", + "position": { "left": 10, "top": 60, "width": 120, "height": 40 } + }), + }, + temp_dir.path(), + )?; + let third_text = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_text_shape".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "text": "Third KPI", + "position": { "left": 10, "top": 110, "width": 120, "height": 40 } + }), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "set_notes".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "text": "Speaker note" + }), + }, + temp_dir.path(), + )?; + let shape = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_shape".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "geometry": "rectangle", + "position": { "left": 180, "top": 10, "width": 180, "height": 90 }, + "text": "Detailed\nShape KPI" + }), + }, + temp_dir.path(), + )?; + + let middle_text_id = second_text + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .and_then(|slide| slide.element_ids.get(1)) + .cloned() + .expect("middle text id"); + let first_text_id = first_text + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .and_then(|slide| slide.element_ids.first()) + .cloned() + .expect("first text id"); + let third_text_id = third_text + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .and_then(|slide| slide.element_ids.get(2)) + .cloned() + .expect("third text id"); + let shape_id = shape + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .and_then(|slide| slide.element_ids.get(3)) + .cloned() + .expect("shape id"); + let slide_id = shape + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .map(|slide| slide.slide_id.clone()) + .expect("slide id"); + + let filtered = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "inspect".to_string(), + args: serde_json::json!({ + "include": "shape,notes", + "exclude": "notes", + "search": "Detailed" + }), + }, + temp_dir.path(), + )?; + let filtered_records = parse_ndjson_lines( + filtered + .inspect_ndjson + .as_deref() + .expect("filtered inspect"), + )?; + assert_eq!(filtered_records.len(), 1); + assert_eq!( + filtered_records[0], + serde_json::json!({ + "kind": "shape", + "id": format!("sh/{shape_id}"), + "slide": 1, + "geometry": "Rectangle", + "text": "Detailed\nShape KPI", + "textStyle": { + "styleName": serde_json::Value::Null, + "fontSize": serde_json::Value::Null, + "fontFamily": serde_json::Value::Null, + "color": serde_json::Value::Null, + "alignment": serde_json::Value::Null, + "bold": false, + "italic": false, + "underline": false + }, + "richText": { + "layout": { + "insets": serde_json::Value::Null, + "wrap": serde_json::Value::Null, + "autoFit": serde_json::Value::Null, + "verticalAlignment": serde_json::Value::Null + }, + "ranges": [] + }, + "rotation": serde_json::Value::Null, + "flipHorizontal": false, + "flipVertical": false, + "bbox": [180, 10, 180, 90], + "bboxUnit": "points", + "textPreview": "Detailed | Shape KPI", + "textChars": 18, + "textLines": 2 + }) + ); + + let targeted = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "inspect".to_string(), + args: serde_json::json!({ + "include": "textbox", + "target": { + "id": format!("sh/{middle_text_id}"), + "before_lines": 1, + "after_lines": 1 + } + }), + }, + temp_dir.path(), + )?; + let targeted_records = parse_ndjson_lines( + targeted + .inspect_ndjson + .as_deref() + .expect("targeted inspect"), + )?; + assert_eq!( + targeted_records + .iter() + .filter_map(|record| record.get("id").and_then(serde_json::Value::as_str)) + .map(str::to_owned) + .collect::>(), + vec![ + format!("sh/{first_text_id}"), + format!("sh/{middle_text_id}"), + format!("sh/{third_text_id}") + ] + ); + + let missing_target = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id), + action: "inspect".to_string(), + args: serde_json::json!({ + "include": "textbox", + "target": { + "id": "sh/missing", + "before_lines": 1, + "after_lines": 1 + } + }), + }, + temp_dir.path(), + )?; + let missing_target_records = parse_ndjson_lines( + missing_target + .inspect_ndjson + .as_deref() + .expect("missing target inspect"), + )?; + assert_eq!( + missing_target_records, + vec![serde_json::json!({ + "kind": "notice", + "noticeType": "targetNotFound", + "target": { + "id": "sh/missing", + "beforeLines": 1, + "afterLines": 1 + }, + "message": "No inspect records matched target `sh/missing`." + })] + ); + + let resolved_shape = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(missing_target.artifact_id.clone()), + action: "resolve".to_string(), + args: serde_json::json!({ "id": format!("sh/{shape_id}") }), + }, + temp_dir.path(), + )?; + assert_eq!( + resolved_shape + .resolved_record + .as_ref() + .and_then(|record| record.get("textPreview")) + .and_then(serde_json::Value::as_str), + Some("Detailed | Shape KPI") + ); + assert_eq!( + resolved_shape + .resolved_record + .as_ref() + .and_then(|record| record.get("textChars")) + .and_then(serde_json::Value::as_u64), + Some(18) + ); + assert_eq!( + resolved_shape + .resolved_record + .as_ref() + .and_then(|record| record.get("textLines")) + .and_then(serde_json::Value::as_u64), + Some(2) + ); + + let resolved_notes = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(missing_target.artifact_id), + action: "resolve".to_string(), + args: serde_json::json!({ "id": format!("nt/{slide_id}") }), + }, + temp_dir.path(), + )?; + assert_eq!( + resolved_notes + .resolved_record + .as_ref() + .and_then(|record| record.get("textPreview")) + .and_then(serde_json::Value::as_str), + Some("Speaker note") + ); + assert_eq!( + resolved_notes + .resolved_record + .as_ref() + .and_then(|record| record.get("textChars")) + .and_then(serde_json::Value::as_u64), + Some(12) + ); + assert_eq!( + resolved_notes + .resolved_record + .as_ref() + .and_then(|record| record.get("textLines")) + .and_then(serde_json::Value::as_u64), + Some(1) + ); + Ok(()) +} + +#[test] +fn notes_visibility_controls_exported_notes() -> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Notes" }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({ "notes": "Hidden notes" }), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "set_notes_visibility".to_string(), + args: serde_json::json!({ "slide_index": 0, "visible": false }), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id), + action: "export_pptx".to_string(), + args: serde_json::json!({ "path": "notes-hidden.pptx" }), + }, + temp_dir.path(), + )?; + + let imported = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "import_pptx".to_string(), + args: serde_json::json!({ "path": "notes-hidden.pptx" }), + }, + temp_dir.path(), + )?; + let summary = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(imported.artifact_id), + action: "list_slides".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + assert_eq!( + summary + .slide_list + .as_ref() + .and_then(|slides| slides.first()) + .and_then(|slide| slide.notes.clone()), + None + ); + Ok(()) +} + +#[test] +fn image_placeholders_and_anchor_updates_work() -> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Images" }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + let placeholder = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_image".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "position": { "left": 24, "top": 24, "width": 200, "height": 120 }, + "fit": "contain", + "prompt": "Generate a hero illustration", + "alt": "Hero placeholder" + }), + }, + temp_dir.path(), + )?; + let image_anchor = placeholder + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .and_then(|slide| slide.element_ids.first()) + .map(|id| format!("im/{id}")) + .expect("image anchor"); + + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "update_shape_style".to_string(), + args: serde_json::json!({ + "element_id": image_anchor, + "fit": "cover", + "crop": { "left": 0.1, "top": 0.0, "right": 0.1, "bottom": 0.0 }, + "rotation": 12, + "flip_horizontal": true, + "lock_aspect_ratio": true + }), + }, + temp_dir.path(), + )?; + + let resolved = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id), + action: "resolve".to_string(), + args: serde_json::json!({ "id": image_anchor }), + }, + temp_dir.path(), + )?; + let record = resolved.resolved_record.expect("resolved image"); + assert_eq!( + record.get("kind").and_then(serde_json::Value::as_str), + Some("image") + ); + assert_eq!( + record + .get("isPlaceholder") + .and_then(serde_json::Value::as_bool), + Some(true) + ); + assert_eq!( + record + .get("lockAspectRatio") + .and_then(serde_json::Value::as_bool), + Some(true) + ); + assert_eq!( + record.get("rotation").and_then(serde_json::Value::as_i64), + Some(12) + ); + assert_eq!( + record + .get("flipHorizontal") + .and_then(serde_json::Value::as_bool), + Some(true) + ); + assert_eq!( + record + .get("flipVertical") + .and_then(serde_json::Value::as_bool), + Some(false) + ); + Ok(()) +} + +#[test] +fn image_partial_resize_respects_lock_aspect_ratio() -> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Image Resize" }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + let added = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_image".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "position": { "left": 10, "top": 10, "width": 200, "height": 100 }, + "prompt": "Placeholder image", + "lock_aspect_ratio": true + }), + }, + temp_dir.path(), + )?; + let image_anchor = added + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .and_then(|slide| slide.element_ids.first()) + .map(|id| format!("im/{id}")) + .expect("image anchor"); + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "update_shape_style".to_string(), + args: serde_json::json!({ + "element_id": image_anchor, + "position": { "width": 120 } + }), + }, + temp_dir.path(), + )?; + let resolved = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id), + action: "resolve".to_string(), + args: serde_json::json!({ "id": image_anchor }), + }, + temp_dir.path(), + )?; + let bbox = resolved + .resolved_record + .as_ref() + .and_then(|record| record.get("bbox")) + .and_then(serde_json::Value::as_array) + .expect("bbox"); + assert_eq!(bbox[2].as_u64(), Some(120)); + assert_eq!(bbox[3].as_u64(), Some(60)); + Ok(()) +} + +#[test] +fn connectors_support_arrows_and_inspect() -> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Connectors" }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + let added = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_connector".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "connector_type": "elbow", + "start": { "left": 20, "top": 20 }, + "end": { "left": 180, "top": 160 }, + "line": { "color": "#ff0000", "width": 2, "style": "dash-dot" }, + "start_arrow": "none", + "end_arrow": "triangle", + "arrow_size": "large", + "label": "flow" + }), + }, + temp_dir.path(), + )?; + let connector_id = added + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .and_then(|slide| slide.element_ids.first()) + .map(|id| format!("cn/{id}")) + .expect("connector id"); + let resolved = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "resolve".to_string(), + args: serde_json::json!({ "id": connector_id }), + }, + temp_dir.path(), + )?; + assert_eq!( + resolved + .resolved_record + .as_ref() + .and_then(|record| record.get("kind")) + .and_then(serde_json::Value::as_str), + Some("connector") + ); + let inspect = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id), + action: "inspect".to_string(), + args: serde_json::json!({ "kind": "connector" }), + }, + temp_dir.path(), + )?; + assert!( + inspect + .inspect_ndjson + .expect("connector inspect") + .contains("\"kind\":\"connector\"") + ); + Ok(()) +} + +#[test] +fn shapes_support_stroke_dash_styles() -> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Shape Strokes" }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + let added_shape = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_shape".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "geometry": "rectangle", + "position": { + "left": 24, + "top": 24, + "width": 180, + "height": 120, + "rotation": 15, + "flip_horizontal": true, + "flip_vertical": true + }, + "stroke": { "color": "#ff0000", "width": 2, "style": "dash-dot" } + }), + }, + temp_dir.path(), + )?; + let shape_id = added_shape + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .and_then(|slide| slide.element_ids.first()) + .cloned() + .expect("shape id"); + + let resolved = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "resolve".to_string(), + args: serde_json::json!({ "id": format!("sh/{shape_id}") }), + }, + temp_dir.path(), + )?; + assert_eq!( + resolved + .resolved_record + .as_ref() + .and_then(|record| record.get("stroke")) + .and_then(|stroke| stroke.get("style")) + .and_then(serde_json::Value::as_str), + Some("dash-dot") + ); + assert_eq!( + resolved + .resolved_record + .as_ref() + .and_then(|record| record.get("rotation")) + .and_then(serde_json::Value::as_i64), + Some(15) + ); + assert_eq!( + resolved + .resolved_record + .as_ref() + .and_then(|record| record.get("flipHorizontal")) + .and_then(serde_json::Value::as_bool), + Some(true) + ); + assert_eq!( + resolved + .resolved_record + .as_ref() + .and_then(|record| record.get("flipVertical")) + .and_then(serde_json::Value::as_bool), + Some(true) + ); + + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "update_shape_style".to_string(), + args: serde_json::json!({ + "element_id": format!("sh/{shape_id}"), + "position": { + "rotation": 30, + "flip_horizontal": false, + "flip_vertical": true + } + }), + }, + temp_dir.path(), + )?; + let updated = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "resolve".to_string(), + args: serde_json::json!({ "id": format!("sh/{shape_id}") }), + }, + temp_dir.path(), + )?; + assert_eq!( + updated + .resolved_record + .as_ref() + .and_then(|record| record.get("rotation")) + .and_then(serde_json::Value::as_i64), + Some(30) + ); + assert_eq!( + updated + .resolved_record + .as_ref() + .and_then(|record| record.get("flipHorizontal")) + .and_then(serde_json::Value::as_bool), + Some(false) + ); + + let export_path = temp_dir.path().join("shape-strokes.pptx"); + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id), + action: "export_pptx".to_string(), + args: serde_json::json!({ "path": export_path }), + }, + temp_dir.path(), + )?; + + let slide_xml = zip_entry_text( + &temp_dir.path().join("shape-strokes.pptx"), + "ppt/slides/slide1.xml", + )?; + assert!(slide_xml.contains(r#""#)); + assert!(slide_xml.contains(r#""#)); + Ok(()) +} + +#[test] +fn z_order_helpers_resequence_elements() -> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Z Order" }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + let first = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_text_shape".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "text": "A", + "position": { "left": 10, "top": 10, "width": 100, "height": 40 } + }), + }, + temp_dir.path(), + )?; + let second = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_text_shape".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "text": "B", + "position": { "left": 20, "top": 20, "width": 100, "height": 40 } + }), + }, + temp_dir.path(), + )?; + let first_id = first + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .and_then(|slide| slide.element_ids.first()) + .cloned() + .expect("first id"); + let second_id = second + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .and_then(|slide| slide.element_ids.last()) + .cloned() + .expect("second id"); + let sent_back = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "send_to_back".to_string(), + args: serde_json::json!({ "element_id": format!("sh/{second_id}") }), + }, + temp_dir.path(), + )?; + assert_eq!( + sent_back + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .map(|slide| slide.element_ids.clone()), + Some(vec![second_id.clone(), first_id.clone()]) + ); + let brought_front = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id), + action: "bring_to_front".to_string(), + args: serde_json::json!({ "element_id": format!("sh/{second_id}") }), + }, + temp_dir.path(), + )?; + assert_eq!( + brought_front + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .map(|slide| slide.element_ids.clone()), + Some(vec![first_id, second_id]) + ); + Ok(()) +} + +#[test] +fn manager_supports_table_cell_updates_and_merges() -> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Tables" }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + let table = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_table".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "position": { "left": 24, "top": 24, "width": 240, "height": 120 }, + "rows": [["A", "B"], ["C", "D"]], + "column_widths": [90, 150], + "row_heights": [40, 80], + "style": "TableStyleMedium9" + }), + }, + temp_dir.path(), + )?; + let table_id = table + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .and_then(|slide| slide.element_ids.first()) + .cloned() + .expect("table id"); + + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "update_table_cell".to_string(), + args: serde_json::json!({ + "element_id": table_id, + "row": 0, + "column": 1, + "value": "Updated", + "background_fill": "#eeeeee", + "alignment": "right", + "styling": { "bold": true } + }), + }, + temp_dir.path(), + )?; + let inspect = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "inspect".to_string(), + args: serde_json::json!({ "kind": "table" }), + }, + temp_dir.path(), + )?; + assert!( + inspect + .inspect_ndjson + .expect("inspect") + .contains("\"kind\":\"table\"") + ); + let resolved = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "resolve".to_string(), + args: serde_json::json!({ "id": format!("tb/{table_id}") }), + }, + temp_dir.path(), + )?; + assert_eq!( + resolved + .resolved_record + .as_ref() + .and_then(|record| record.get("columnWidths")) + .and_then(serde_json::Value::as_array) + .map(|widths| widths + .iter() + .filter_map(serde_json::Value::as_u64) + .collect::>()), + Some(vec![90, 150]) + ); + assert_eq!( + resolved + .resolved_record + .as_ref() + .and_then(|record| record.get("rowHeights")) + .and_then(serde_json::Value::as_array) + .map(|heights| heights + .iter() + .filter_map(serde_json::Value::as_u64) + .collect::>()), + Some(vec![40, 80]) + ); + + let merged = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id), + action: "merge_table_cells".to_string(), + args: serde_json::json!({ + "element_id": table_id, + "start_row": 0, + "end_row": 0, + "start_column": 0, + "end_column": 1 + }), + }, + temp_dir.path(), + )?; + assert_eq!( + merged + .artifact_snapshot + .as_ref() + .map(|snapshot| snapshot.slide_count), + Some(1) + ); + let export_path = temp_dir.path().join("tables.pptx"); + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(merged.artifact_id), + action: "export_pptx".to_string(), + args: serde_json::json!({ "path": export_path }), + }, + temp_dir.path(), + )?; + let slide_xml = zip_entry_text( + &temp_dir.path().join("tables.pptx"), + "ppt/slides/slide1.xml", + )?; + assert!( + slide_xml.contains(r#""#), + "{slide_xml}" + ); + assert!(slide_xml.contains(r#""#)); + assert!(slide_xml.contains(r#""#)); + assert!(slide_xml.contains(r#""#)); + Ok(()) +} + +#[test] +fn rich_text_comments_tables_and_charts_roundtrip_through_metadata() +-> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Parity Roundtrip" }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + + let text_added = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_text_shape".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "text": "Placeholder", + "position": { "left": 32, "top": 28, "width": 280, "height": 72 } + }), + }, + temp_dir.path(), + )?; + let text_id = text_added + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .and_then(|slide| slide.element_ids.first()) + .cloned() + .expect("text id"); + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "set_rich_text".to_string(), + args: serde_json::json!({ + "element_id": text_id, + "text": [[ + { + "run": "Quarterly ", + "text_style": { "bold": true, "color": "#114488" } + }, + "update pipeline" + ]], + "text_layout": { + "wrap": "square", + "auto_fit": "shrinkText", + "vertical_alignment": "middle", + "insets": { "left": 6, "right": 6, "top": 4, "bottom": 4 } + } + }), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "format_text_range".to_string(), + args: serde_json::json!({ + "element_id": text_id, + "query": "update", + "styling": { "italic": true }, + "link": { "uri": "https://example.com/update", "is_external": true } + }), + }, + temp_dir.path(), + )?; + + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "set_comment_author".to_string(), + args: serde_json::json!({ + "display_name": "Jamie Fox", + "initials": "JF", + "email": "jamie@example.com" + }), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_comment_thread".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "element_id": text_id, + "query": "Quarterly", + "text": "Tighten this headline", + "position": { "x": 240, "y": 44 } + }), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_comment_reply".to_string(), + args: serde_json::json!({ + "thread_id": "thread_1", + "text": "Applied to the slide draft." + }), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "toggle_comment_reaction".to_string(), + args: serde_json::json!({ + "thread_id": "thread_1", + "emoji": "eyes" + }), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "resolve_comment_thread".to_string(), + args: serde_json::json!({ "thread_id": "thread_1" }), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "reopen_comment_thread".to_string(), + args: serde_json::json!({ "thread_id": "thread_1" }), + }, + temp_dir.path(), + )?; + + let table_added = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_table".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "position": { "left": 32, "top": 124, "width": 320, "height": 120 }, + "rows": [["Metric", "Value"], ["Status", "Beta"]], + "style": "TableStyleMedium2", + "style_options": { + "header_row": true, + "banded_rows": true, + "first_column": true + }, + "borders": { + "outside": { "color": "#222222", "width": 2 } + }, + "right_to_left": true + }), + }, + temp_dir.path(), + )?; + let table_id = table_added + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .and_then(|slide| slide.element_ids.last()) + .cloned() + .expect("table id"); + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "update_table_style".to_string(), + args: serde_json::json!({ + "element_id": table_id, + "style_options": { "last_column": true, "total_row": true }, + "borders": { + "inside": { "color": "#999999", "width": 1 } + } + }), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "style_table_block".to_string(), + args: serde_json::json!({ + "element_id": table_id, + "row": 1, + "column": 0, + "row_count": 1, + "column_count": 2, + "background_fill": "#FFF2CC", + "alignment": "center", + "styling": { "bold": true } + }), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "format_text_range".to_string(), + args: serde_json::json!({ + "element_id": table_id, + "row": 1, + "column": 1, + "query": "Beta", + "styling": { "italic": true, "color": "#AA0000" } + }), + }, + temp_dir.path(), + )?; + + let chart_added = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_chart".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "position": { "left": 372, "top": 120, "width": 280, "height": 210 }, + "chart_type": "bar", + "categories": ["Q1", "Q2"], + "series": [{ + "name": "Revenue", + "values": [10.0, 12.0], + "fill": "#4472C4", + "stroke": { "color": "#1F4E79", "width": 2 }, + "marker": { "symbol": "circle", "size": 7 }, + "data_label_overrides": [{ + "idx": 1, + "text": "12M", + "position": "outsideEnd", + "fill": "#FFFFFF" + }] + }], + "title": "Revenue", + "style_index": 7, + "has_legend": true, + "legend_position": "right", + "legend_text_style": { "italic": true }, + "x_axis_title": "Quarter", + "y_axis_title": "USD", + "data_labels": { + "show_value": true, + "position": "outsideEnd", + "text_style": { "bold": true } + }, + "chart_fill": "#F8F8F8", + "plot_area_fill": "#FFFFFF" + }), + }, + temp_dir.path(), + )?; + let chart_id = chart_added + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .and_then(|slide| slide.element_ids.last()) + .cloned() + .expect("chart id"); + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "update_chart".to_string(), + args: serde_json::json!({ + "element_id": chart_id, + "title": "Revenue outlook", + "style_index": 12, + "legend_position": "bottom", + "y_axis_title": "USD (millions)" + }), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_chart_series".to_string(), + args: serde_json::json!({ + "element_id": chart_id, + "name": "Target", + "values": [11.0, 13.0], + "fill": "#70AD47", + "marker": { "symbol": "diamond", "size": 6 } + }), + }, + temp_dir.path(), + )?; + + let export_path = temp_dir.path().join("parity-roundtrip.pptx"); + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id), + action: "export_pptx".to_string(), + args: serde_json::json!({ "path": export_path }), + }, + temp_dir.path(), + )?; + let metadata = zip_entry_text( + &temp_dir.path().join("parity-roundtrip.pptx"), + "ppt/codex-document.json", + )?; + assert!(metadata.contains("\"thread_id\":\"thread_1\"")); + assert!(metadata.contains("\"style_index\":12")); + assert!(metadata.contains("\"right_to_left\":true")); + + let imported = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "import_pptx".to_string(), + args: serde_json::json!({ "path": "parity-roundtrip.pptx" }), + }, + temp_dir.path(), + )?; + let imported_artifact_id = imported.artifact_id; + + let proto = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(imported_artifact_id.clone()), + action: "to_proto".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + let proto = proto.proto_json.expect("proto"); + let comments = proto["commentThreads"].as_array().expect("comments"); + assert_eq!(comments.len(), 1); + assert_eq!(comments[0]["status"], "active"); + assert_eq!(comments[0]["messages"].as_array().map(Vec::len), Some(2)); + assert_eq!( + comments[0]["messages"][1]["reactions"], + serde_json::json!(["eyes"]) + ); + + let elements = proto["slides"][0]["elements"].as_array().expect("elements"); + let text_record = elements + .iter() + .find(|element| element["elementId"] == text_id) + .expect("text record"); + assert_eq!(text_record["richText"]["layout"]["wrap"], "square"); + assert_eq!(text_record["richText"]["layout"]["autoFit"], "shrinkText"); + assert_eq!( + text_record["richText"]["layout"]["verticalAlignment"], + "middle" + ); + assert_eq!( + text_record["richText"]["ranges"].as_array().map(Vec::len), + Some(2) + ); + assert!( + text_record["richText"]["ranges"] + .as_array() + .expect("text ranges") + .iter() + .any(|range| range["text"] == "update") + ); + + let table_record = elements + .iter() + .find(|element| element["elementId"] == table_id) + .expect("table record"); + assert_eq!(table_record["styleOptions"]["headerRow"], true); + assert_eq!(table_record["styleOptions"]["lastColumn"], true); + assert_eq!(table_record["rightToLeft"], true); + assert_eq!(table_record["borders"]["outside"]["color"], "222222"); + assert_eq!( + table_record["rows"][1][1]["richText"]["ranges"] + .as_array() + .map(Vec::len), + Some(1) + ); + + let chart_record = elements + .iter() + .find(|element| element["elementId"] == chart_id) + .expect("chart record"); + assert_eq!(chart_record["styleIndex"], 12); + assert_eq!(chart_record["legend"]["position"], "bottom"); + assert_eq!(chart_record["series"].as_array().map(Vec::len), Some(2)); + assert_eq!(chart_record["series"][1]["name"], "Target"); + + let inspect = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(imported_artifact_id.clone()), + action: "inspect".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + let inspect_ndjson = inspect.inspect_ndjson.expect("inspect"); + assert!(inspect_ndjson.contains("\"kind\":\"comment\"")); + assert!(inspect_ndjson.contains("\"kind\":\"textRange\"")); + assert!(inspect_ndjson.contains("\"chartType\":\"Bar\"")); + + let resolved_thread = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(imported_artifact_id.clone()), + action: "resolve".to_string(), + args: serde_json::json!({ "id": "th/thread_1" }), + }, + temp_dir.path(), + )?; + assert_eq!( + resolved_thread + .resolved_record + .as_ref() + .and_then(|record| record.get("target")) + .and_then(|target| target.get("type")), + Some(&serde_json::json!("textRange")) + ); + + let resolved_range = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(imported_artifact_id), + action: "resolve".to_string(), + args: serde_json::json!({ "id": "tr/range_1" }), + }, + temp_dir.path(), + )?; + assert_eq!( + resolved_range + .resolved_record + .as_ref() + .and_then(|record| record.get("text")), + Some(&serde_json::json!("update")) + ); + Ok(()) +} + +#[test] +fn history_can_undo_and_redo_created_artifact() -> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "History" }), + }, + temp_dir.path(), + )?; + + let undone = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(created.artifact_id.clone()), + action: "undo".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + assert_eq!(undone.artifact_id, created.artifact_id); + assert!(undone.artifact_snapshot.is_none()); + assert!( + manager + .execute( + PresentationArtifactRequest { + artifact_id: Some(created.artifact_id.clone()), + action: "get_summary".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + ) + .is_err() + ); + + let redone = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(created.artifact_id.clone()), + action: "redo".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + assert_eq!(redone.artifact_id, created.artifact_id); + assert_eq!( + redone + .artifact_snapshot + .as_ref() + .map(|snapshot| snapshot.slide_count), + Some(0) + ); + Ok(()) +} + +#[test] +fn proto_and_patch_actions_work_and_patch_history_is_atomic() +-> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Proto Patch" }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + + let patch_ops = serde_json::json!([ + { + "action": "add_text_shape", + "args": { + "slide_index": 0, + "text": "Patch text", + "position": { "left": 40, "top": 60, "width": 180, "height": 50 } + } + }, + { + "action": "set_slide_background", + "args": { + "slide_index": 0, + "fill": "#ffeecc" + } + } + ]); + let recorded = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "record_patch".to_string(), + args: serde_json::json!({ "operations": patch_ops }), + }, + temp_dir.path(), + )?; + let expected_patch = serde_json::json!({ + "version": 1, + "artifactId": artifact_id, + "operations": [ + { + "action": "add_text_shape", + "args": { + "slide_index": 0, + "text": "Patch text", + "position": { "left": 40, "top": 60, "width": 180, "height": 50 } + } + }, + { + "action": "set_slide_background", + "args": { + "slide_index": 0, + "fill": "#ffeecc" + } + } + ] + }); + assert_eq!(recorded.patch.as_ref(), Some(&expected_patch)); + + let applied = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "apply_patch".to_string(), + args: serde_json::json!({ "patch": expected_patch }), + }, + temp_dir.path(), + )?; + let slide_snapshot = applied + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .cloned() + .expect("slide snapshot"); + let slide_id = slide_snapshot.slide_id.clone(); + let element_id = slide_snapshot + .element_ids + .first() + .cloned() + .expect("element id"); + + let proto = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "to_proto".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + let proto = proto.proto_json.expect("proto"); + assert_eq!(proto["kind"], "presentation"); + assert_eq!(proto["artifactId"], artifact_id); + assert_eq!(proto["activeSlideId"], slide_id); + assert_eq!(proto["commentAuthor"], serde_json::Value::Null); + assert_eq!(proto["commentThreads"], serde_json::json!([])); + assert_eq!(proto["masters"], serde_json::json!([])); + assert_eq!(proto["layouts"], serde_json::json!([])); + assert_eq!(proto["theme"]["hexColorMap"], serde_json::json!({})); + assert_eq!(proto["styles"].as_array().map(Vec::len), Some(5)); + assert_eq!(proto["slides"].as_array().map(Vec::len), Some(1)); + assert_eq!(proto["slides"][0]["slideId"], slide_id); + assert_eq!(proto["slides"][0]["backgroundFill"], "FFEECC"); + assert_eq!( + proto["slides"][0]["notes"]["richText"]["ranges"], + serde_json::json!([]) + ); + assert_eq!( + proto["slides"][0]["elements"].as_array().map(Vec::len), + Some(1) + ); + assert_eq!(proto["slides"][0]["elements"][0]["elementId"], element_id); + assert_eq!(proto["slides"][0]["elements"][0]["text"], "Patch text"); + assert_eq!( + proto["slides"][0]["elements"][0]["richText"]["ranges"], + serde_json::json!([]) + ); + + let undone = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "undo".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + assert_eq!( + undone + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .map(|slide| slide.element_ids.clone()), + Some(Vec::new()) + ); + let undone_proto = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "to_proto".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + assert_eq!( + undone_proto + .proto_json + .as_ref() + .and_then(|proto| proto.get("slides")) + .and_then(serde_json::Value::as_array) + .and_then(|slides| slides.first()) + .and_then(|slide| slide.get("backgroundFill")), + Some(&serde_json::Value::Null) + ); + assert_eq!( + undone_proto + .proto_json + .as_ref() + .and_then(|proto| proto.get("slides")) + .and_then(serde_json::Value::as_array) + .and_then(|slides| slides.first()) + .and_then(|slide| slide.get("elements")) + .and_then(serde_json::Value::as_array) + .map(Vec::len), + Some(0) + ); + + let redone = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "redo".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + assert_eq!(redone.patch, None); + let redone_proto = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id), + action: "to_proto".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + assert_eq!(redone_proto.proto_json, Some(proto)); + Ok(()) +} diff --git a/codex-rs/artifact-spreadsheet/BUILD.bazel b/codex-rs/artifact-spreadsheet/BUILD.bazel new file mode 100644 index 0000000000..07d1337283 --- /dev/null +++ b/codex-rs/artifact-spreadsheet/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "artifact-spreadsheet", + crate_name = "codex_artifact_spreadsheet", +) diff --git a/codex-rs/artifact-spreadsheet/Cargo.toml b/codex-rs/artifact-spreadsheet/Cargo.toml new file mode 100644 index 0000000000..66e030ea43 --- /dev/null +++ b/codex-rs/artifact-spreadsheet/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "codex-artifact-spreadsheet" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +name = "codex_artifact_spreadsheet" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +base64 = { workspace = true } +regex = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +thiserror = { workspace = true } +uuid = { workspace = true, features = ["v4"] } +zip = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } diff --git a/codex-rs/artifact-spreadsheet/src/address.rs b/codex-rs/artifact-spreadsheet/src/address.rs new file mode 100644 index 0000000000..28e4da5e3a --- /dev/null +++ b/codex-rs/artifact-spreadsheet/src/address.rs @@ -0,0 +1,245 @@ +use serde::Deserialize; +use serde::Serialize; + +use crate::SpreadsheetArtifactError; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct CellAddress { + pub column: u32, + pub row: u32, +} + +impl CellAddress { + pub fn parse(address: &str) -> Result { + let trimmed = address.trim(); + if trimmed.is_empty() { + return Err(SpreadsheetArtifactError::InvalidAddress { + address: address.to_string(), + message: "address is empty".to_string(), + }); + } + + let mut split = 0usize; + for (index, ch) in trimmed.char_indices() { + if ch.is_ascii_alphabetic() { + split = index + ch.len_utf8(); + } else { + break; + } + } + + let (letters, digits) = trimmed.split_at(split); + if letters.is_empty() || digits.is_empty() { + return Err(SpreadsheetArtifactError::InvalidAddress { + address: address.to_string(), + message: "expected A1-style address".to_string(), + }); + } + + if !letters.chars().all(|ch| ch.is_ascii_alphabetic()) + || !digits.chars().all(|ch| ch.is_ascii_digit()) + { + return Err(SpreadsheetArtifactError::InvalidAddress { + address: address.to_string(), + message: "expected letters followed by digits".to_string(), + }); + } + + let column = column_letters_to_index(letters)?; + let row = digits + .parse::() + .map_err(|_| SpreadsheetArtifactError::InvalidAddress { + address: address.to_string(), + message: "row must be a positive integer".to_string(), + })?; + + if row == 0 { + return Err(SpreadsheetArtifactError::InvalidAddress { + address: address.to_string(), + message: "row must be positive".to_string(), + }); + } + + Ok(Self { column, row }) + } + + pub fn to_a1(self) -> String { + format!("{}{}", column_index_to_letters(self.column), self.row) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CellRange { + pub start: CellAddress, + pub end: CellAddress, +} + +impl CellRange { + pub fn parse(address: &str) -> Result { + let trimmed = address.trim(); + if trimmed.is_empty() { + return Err(SpreadsheetArtifactError::InvalidAddress { + address: address.to_string(), + message: "range is empty".to_string(), + }); + } + + let (start, end) = if let Some((left, right)) = trimmed.split_once(':') { + (CellAddress::parse(left)?, CellAddress::parse(right)?) + } else { + let cell = CellAddress::parse(trimmed)?; + (cell, cell) + }; + + let normalized = Self { + start: CellAddress { + column: start.column.min(end.column), + row: start.row.min(end.row), + }, + end: CellAddress { + column: start.column.max(end.column), + row: start.row.max(end.row), + }, + }; + Ok(normalized) + } + + pub fn from_start_end(start: CellAddress, end: CellAddress) -> Self { + Self { + start: CellAddress { + column: start.column.min(end.column), + row: start.row.min(end.row), + }, + end: CellAddress { + column: start.column.max(end.column), + row: start.row.max(end.row), + }, + } + } + + pub fn to_a1(&self) -> String { + if self.is_single_cell() { + self.start.to_a1() + } else { + format!("{}:{}", self.start.to_a1(), self.end.to_a1()) + } + } + + pub fn is_single_cell(&self) -> bool { + self.start == self.end + } + + pub fn is_single_row(&self) -> bool { + self.start.row == self.end.row + } + + pub fn is_single_column(&self) -> bool { + self.start.column == self.end.column + } + + pub fn width(&self) -> usize { + (self.end.column - self.start.column + 1) as usize + } + + pub fn height(&self) -> usize { + (self.end.row - self.start.row + 1) as usize + } + + pub fn contains(&self, address: CellAddress) -> bool { + self.start.column <= address.column + && address.column <= self.end.column + && self.start.row <= address.row + && address.row <= self.end.row + } + + pub fn contains_range(&self, other: &CellRange) -> bool { + self.contains(other.start) && self.contains(other.end) + } + + pub fn intersects(&self, other: &CellRange) -> bool { + !(self.end.column < other.start.column + || other.end.column < self.start.column + || self.end.row < other.start.row + || other.end.row < self.start.row) + } + + pub fn addresses(&self) -> impl Iterator { + let range = self.clone(); + (range.start.row..=range.end.row).flat_map(move |row| { + let range = range.clone(); + (range.start.column..=range.end.column).map(move |column| CellAddress { column, row }) + }) + } +} + +pub fn column_letters_to_index(column: &str) -> Result { + let trimmed = column.trim(); + if trimmed.is_empty() { + return Err(SpreadsheetArtifactError::InvalidAddress { + address: column.to_string(), + message: "column is empty".to_string(), + }); + } + + let mut result = 0u32; + for ch in trimmed.chars() { + if !ch.is_ascii_alphabetic() { + return Err(SpreadsheetArtifactError::InvalidAddress { + address: column.to_string(), + message: "column must contain only letters".to_string(), + }); + } + result = result + .checked_mul(26) + .and_then(|value| value.checked_add((ch.to_ascii_uppercase() as u8 - b'A' + 1) as u32)) + .ok_or_else(|| SpreadsheetArtifactError::InvalidAddress { + address: column.to_string(), + message: "column is too large".to_string(), + })?; + } + Ok(result) +} + +pub fn column_index_to_letters(mut index: u32) -> String { + if index == 0 { + return String::new(); + } + + let mut letters = Vec::new(); + while index > 0 { + let remainder = (index - 1) % 26; + letters.push((b'A' + remainder as u8) as char); + index = (index - 1) / 26; + } + letters.iter().rev().collect() +} + +pub fn parse_column_reference(reference: &str) -> Result<(u32, u32), SpreadsheetArtifactError> { + let trimmed = reference.trim(); + if let Some((left, right)) = trimmed.split_once(':') { + let start = column_letters_to_index(left)?; + let end = column_letters_to_index(right)?; + Ok((start.min(end), start.max(end))) + } else { + let column = column_letters_to_index(trimmed)?; + Ok((column, column)) + } +} + +pub fn is_valid_cell_reference(address: &str) -> bool { + CellAddress::parse(address).is_ok() +} + +pub fn is_valid_range_reference(address: &str) -> bool { + CellRange::parse(address).is_ok() +} + +pub fn is_valid_row_reference(address: &str) -> bool { + CellRange::parse(address) + .map(|range| range.is_single_row()) + .unwrap_or(false) +} + +pub fn is_valid_column_reference(address: &str) -> bool { + parse_column_reference(address).is_ok() +} diff --git a/codex-rs/artifact-spreadsheet/src/chart.rs b/codex-rs/artifact-spreadsheet/src/chart.rs new file mode 100644 index 0000000000..83b02b6be1 --- /dev/null +++ b/codex-rs/artifact-spreadsheet/src/chart.rs @@ -0,0 +1,357 @@ +use serde::Deserialize; +use serde::Serialize; + +use crate::CellAddress; +use crate::CellRange; +use crate::SpreadsheetArtifactError; +use crate::SpreadsheetSheet; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SpreadsheetChartType { + Area, + Bar, + Doughnut, + Line, + Pie, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SpreadsheetChartLegendPosition { + Bottom, + Top, + Left, + Right, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpreadsheetChartLegend { + pub visible: bool, + pub position: SpreadsheetChartLegendPosition, + pub overlay: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpreadsheetChartAxis { + pub linked_number_format: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpreadsheetChartSeries { + pub id: u32, + pub name: Option, + pub category_sheet_name: Option, + pub category_range: String, + pub value_sheet_name: Option, + pub value_range: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpreadsheetChart { + pub id: u32, + pub chart_type: SpreadsheetChartType, + pub source_sheet_name: Option, + pub source_range: Option, + pub title: Option, + pub style_index: u32, + pub display_blanks_as: String, + pub legend: SpreadsheetChartLegend, + pub category_axis: SpreadsheetChartAxis, + pub value_axis: SpreadsheetChartAxis, + #[serde(default)] + pub series: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct SpreadsheetChartLookup { + pub id: Option, + pub index: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpreadsheetChartCreateOptions { + pub id: Option, + pub title: Option, + pub legend_visible: Option, + pub legend_position: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpreadsheetChartProperties { + pub title: Option, + pub legend_visible: Option, + pub legend_position: Option, +} + +impl SpreadsheetSheet { + pub fn list_charts( + &self, + range: Option<&CellRange>, + ) -> Result, SpreadsheetArtifactError> { + Ok(self + .charts + .iter() + .filter(|chart| { + range.is_none_or(|target| { + chart + .source_range + .as_deref() + .map(CellRange::parse) + .transpose() + .ok() + .flatten() + .is_some_and(|chart_range| chart_range.intersects(target)) + }) + }) + .cloned() + .collect()) + } + + pub fn get_chart( + &self, + action: &str, + lookup: SpreadsheetChartLookup, + ) -> Result<&SpreadsheetChart, SpreadsheetArtifactError> { + if let Some(id) = lookup.id { + return self + .charts + .iter() + .find(|chart| chart.id == id) + .ok_or_else(|| SpreadsheetArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("chart id `{id}` was not found"), + }); + } + if let Some(index) = lookup.index { + return self.charts.get(index).ok_or_else(|| { + SpreadsheetArtifactError::IndexOutOfRange { + action: action.to_string(), + index, + len: self.charts.len(), + } + }); + } + Err(SpreadsheetArtifactError::InvalidArgs { + action: action.to_string(), + message: "chart id or index is required".to_string(), + }) + } + + pub fn create_chart( + &mut self, + action: &str, + chart_type: SpreadsheetChartType, + source_sheet_name: Option, + source_range: &CellRange, + options: SpreadsheetChartCreateOptions, + ) -> Result { + if source_range.width() < 2 { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: action.to_string(), + message: "chart source range must include at least two columns".to_string(), + }); + } + let id = if let Some(id) = options.id { + if self.charts.iter().any(|chart| chart.id == id) { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("chart id `{id}` already exists"), + }); + } + id + } else { + self.charts.iter().map(|chart| chart.id).max().unwrap_or(0) + 1 + }; + let series = (source_range.start.column + 1..=source_range.end.column) + .enumerate() + .map(|(index, value_column)| SpreadsheetChartSeries { + id: index as u32 + 1, + name: None, + category_sheet_name: source_sheet_name.clone(), + category_range: CellRange::from_start_end( + source_range.start, + CellAddress { + column: source_range.start.column, + row: source_range.end.row, + }, + ) + .to_a1(), + value_sheet_name: source_sheet_name.clone(), + value_range: CellRange::from_start_end( + CellAddress { + column: value_column, + row: source_range.start.row, + }, + CellAddress { + column: value_column, + row: source_range.end.row, + }, + ) + .to_a1(), + }) + .collect::>(); + self.charts.push(SpreadsheetChart { + id, + chart_type, + source_sheet_name, + source_range: Some(source_range.to_a1()), + title: options.title, + style_index: 102, + display_blanks_as: "gap".to_string(), + legend: SpreadsheetChartLegend { + visible: options.legend_visible.unwrap_or(true), + position: options + .legend_position + .unwrap_or(SpreadsheetChartLegendPosition::Bottom), + overlay: false, + }, + category_axis: SpreadsheetChartAxis { + linked_number_format: true, + }, + value_axis: SpreadsheetChartAxis { + linked_number_format: true, + }, + series, + }); + Ok(id) + } + + pub fn add_chart_series( + &mut self, + action: &str, + lookup: SpreadsheetChartLookup, + mut series: SpreadsheetChartSeries, + ) -> Result { + validate_chart_series(action, &series)?; + let chart = self.get_chart_mut(action, lookup)?; + let next_id = chart.series.iter().map(|entry| entry.id).max().unwrap_or(0) + 1; + series.id = next_id; + chart.series.push(series); + Ok(next_id) + } + + pub fn delete_chart( + &mut self, + action: &str, + lookup: SpreadsheetChartLookup, + ) -> Result<(), SpreadsheetArtifactError> { + let index = if let Some(id) = lookup.id { + self.charts + .iter() + .position(|chart| chart.id == id) + .ok_or_else(|| SpreadsheetArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("chart id `{id}` was not found"), + })? + } else if let Some(index) = lookup.index { + if index >= self.charts.len() { + return Err(SpreadsheetArtifactError::IndexOutOfRange { + action: action.to_string(), + index, + len: self.charts.len(), + }); + } + index + } else { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: action.to_string(), + message: "chart id or index is required".to_string(), + }); + }; + self.charts.remove(index); + Ok(()) + } + + pub fn set_chart_properties( + &mut self, + action: &str, + lookup: SpreadsheetChartLookup, + properties: SpreadsheetChartProperties, + ) -> Result<(), SpreadsheetArtifactError> { + let chart = self.get_chart_mut(action, lookup)?; + if let Some(title) = properties.title { + chart.title = Some(title); + } + if let Some(visible) = properties.legend_visible { + chart.legend.visible = visible; + } + if let Some(position) = properties.legend_position { + chart.legend.position = position; + } + Ok(()) + } + + pub fn validate_charts(&self, action: &str) -> Result<(), SpreadsheetArtifactError> { + for chart in &self.charts { + if let Some(source_range) = &chart.source_range { + let range = CellRange::parse(source_range)?; + if range.width() < 2 { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: action.to_string(), + message: format!( + "chart `{}` source range `{source_range}` is too narrow", + chart.id + ), + }); + } + } + for series in &chart.series { + validate_chart_series(action, series)?; + } + } + Ok(()) + } + + fn get_chart_mut( + &mut self, + action: &str, + lookup: SpreadsheetChartLookup, + ) -> Result<&mut SpreadsheetChart, SpreadsheetArtifactError> { + if let Some(id) = lookup.id { + return self + .charts + .iter_mut() + .find(|chart| chart.id == id) + .ok_or_else(|| SpreadsheetArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("chart id `{id}` was not found"), + }); + } + if let Some(index) = lookup.index { + let len = self.charts.len(); + return self.charts.get_mut(index).ok_or_else(|| { + SpreadsheetArtifactError::IndexOutOfRange { + action: action.to_string(), + index, + len, + } + }); + } + Err(SpreadsheetArtifactError::InvalidArgs { + action: action.to_string(), + message: "chart id or index is required".to_string(), + }) + } +} + +fn validate_chart_series( + action: &str, + series: &SpreadsheetChartSeries, +) -> Result<(), SpreadsheetArtifactError> { + let category_range = CellRange::parse(&series.category_range)?; + let value_range = CellRange::parse(&series.value_range)?; + if !category_range.is_single_column() || !value_range.is_single_column() { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: action.to_string(), + message: "chart category and value ranges must be single-column ranges".to_string(), + }); + } + if category_range.height() != value_range.height() { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: action.to_string(), + message: "chart category and value series lengths must match".to_string(), + }); + } + Ok(()) +} diff --git a/codex-rs/artifact-spreadsheet/src/conditional.rs b/codex-rs/artifact-spreadsheet/src/conditional.rs new file mode 100644 index 0000000000..96c0c8ebd8 --- /dev/null +++ b/codex-rs/artifact-spreadsheet/src/conditional.rs @@ -0,0 +1,308 @@ +use serde::Deserialize; +use serde::Serialize; + +use crate::CellRange; +use crate::SpreadsheetArtifact; +use crate::SpreadsheetArtifactError; +use crate::SpreadsheetSheet; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SpreadsheetConditionalFormatType { + Expression, + CellIs, + ColorScale, + DataBar, + IconSet, + Top10, + UniqueValues, + DuplicateValues, + ContainsText, + NotContainsText, + BeginsWith, + EndsWith, + ContainsBlanks, + NotContainsBlanks, + ContainsErrors, + NotContainsErrors, + TimePeriod, + AboveAverage, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SpreadsheetColorScale { + pub min_type: Option, + pub mid_type: Option, + pub max_type: Option, + pub min_value: Option, + pub mid_value: Option, + pub max_value: Option, + pub min_color: String, + pub mid_color: Option, + pub max_color: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SpreadsheetDataBar { + pub color: String, + pub min_length: Option, + pub max_length: Option, + pub show_value: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SpreadsheetIconSet { + pub style: String, + pub show_value: Option, + pub reverse_order: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SpreadsheetConditionalFormat { + pub id: u32, + pub range: String, + pub rule_type: SpreadsheetConditionalFormatType, + pub operator: Option, + #[serde(default)] + pub formulas: Vec, + pub text: Option, + pub dxf_id: Option, + pub stop_if_true: bool, + pub priority: u32, + pub rank: Option, + pub percent: Option, + pub time_period: Option, + pub above_average: Option, + pub equal_average: Option, + pub color_scale: Option, + pub data_bar: Option, + pub icon_set: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SpreadsheetConditionalFormatCollection { + pub sheet_name: String, + pub range: String, +} + +impl SpreadsheetConditionalFormatCollection { + pub fn new(sheet_name: String, range: &CellRange) -> Self { + Self { + sheet_name, + range: range.to_a1(), + } + } + + pub fn range(&self) -> Result { + CellRange::parse(&self.range) + } + + pub fn list( + &self, + artifact: &SpreadsheetArtifact, + ) -> Result, SpreadsheetArtifactError> { + let sheet = artifact.sheet_lookup( + "conditional_format_collection", + Some(&self.sheet_name), + None, + )?; + Ok(sheet.list_conditional_formats(Some(&self.range()?))) + } + + pub fn add( + &self, + artifact: &mut SpreadsheetArtifact, + mut format: SpreadsheetConditionalFormat, + ) -> Result { + format.range = self.range.clone(); + artifact.add_conditional_format("conditional_format_collection", &self.sheet_name, format) + } + + pub fn delete( + &self, + artifact: &mut SpreadsheetArtifact, + id: u32, + ) -> Result<(), SpreadsheetArtifactError> { + artifact.delete_conditional_format("conditional_format_collection", &self.sheet_name, id) + } +} + +impl SpreadsheetArtifact { + pub fn validate_conditional_formats( + &self, + action: &str, + sheet_name: &str, + ) -> Result<(), SpreadsheetArtifactError> { + let sheet = self.sheet_lookup(action, Some(sheet_name), None)?; + for format in &sheet.conditional_formats { + validate_conditional_format(self, format, action)?; + } + Ok(()) + } + + pub fn add_conditional_format( + &mut self, + action: &str, + sheet_name: &str, + mut format: SpreadsheetConditionalFormat, + ) -> Result { + validate_conditional_format(self, &format, action)?; + let sheet = self.sheet_lookup_mut(action, Some(sheet_name), None)?; + let next_id = sheet + .conditional_formats + .iter() + .map(|entry| entry.id) + .max() + .unwrap_or(0) + + 1; + format.id = next_id; + format.priority = if format.priority == 0 { + next_id + } else { + format.priority + }; + sheet.conditional_formats.push(format); + Ok(next_id) + } + + pub fn delete_conditional_format( + &mut self, + action: &str, + sheet_name: &str, + id: u32, + ) -> Result<(), SpreadsheetArtifactError> { + let sheet = self.sheet_lookup_mut(action, Some(sheet_name), None)?; + let previous_len = sheet.conditional_formats.len(); + sheet.conditional_formats.retain(|entry| entry.id != id); + if sheet.conditional_formats.len() == previous_len { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("conditional format `{id}` was not found"), + }); + } + Ok(()) + } +} + +impl SpreadsheetSheet { + pub fn conditional_format_collection( + &self, + range: &CellRange, + ) -> SpreadsheetConditionalFormatCollection { + SpreadsheetConditionalFormatCollection::new(self.name.clone(), range) + } + + pub fn list_conditional_formats( + &self, + range: Option<&CellRange>, + ) -> Vec { + self.conditional_formats + .iter() + .filter(|entry| { + range.is_none_or(|target| { + CellRange::parse(&entry.range) + .map(|entry_range| entry_range.intersects(target)) + .unwrap_or(false) + }) + }) + .cloned() + .collect() + } +} + +fn validate_conditional_format( + artifact: &SpreadsheetArtifact, + format: &SpreadsheetConditionalFormat, + action: &str, +) -> Result<(), SpreadsheetArtifactError> { + CellRange::parse(&format.range)?; + if let Some(dxf_id) = format.dxf_id + && artifact.get_differential_format(dxf_id).is_none() + { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("differential format `{dxf_id}` was not found"), + }); + } + + let has_style = format.dxf_id.is_some(); + let has_intrinsic_visual = + format.color_scale.is_some() || format.data_bar.is_some() || format.icon_set.is_some(); + + match format.rule_type { + SpreadsheetConditionalFormatType::Expression | SpreadsheetConditionalFormatType::CellIs => { + if format.formulas.is_empty() { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: action.to_string(), + message: "conditional format formulas are required".to_string(), + }); + } + } + SpreadsheetConditionalFormatType::ContainsText + | SpreadsheetConditionalFormatType::NotContainsText + | SpreadsheetConditionalFormatType::BeginsWith + | SpreadsheetConditionalFormatType::EndsWith => { + if format.text.as_deref().unwrap_or_default().is_empty() { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: action.to_string(), + message: "conditional format text is required".to_string(), + }); + } + } + SpreadsheetConditionalFormatType::ColorScale => { + if format.color_scale.is_none() { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: action.to_string(), + message: "color scale settings are required".to_string(), + }); + } + } + SpreadsheetConditionalFormatType::DataBar => { + if format.data_bar.is_none() { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: action.to_string(), + message: "data bar settings are required".to_string(), + }); + } + } + SpreadsheetConditionalFormatType::IconSet => { + if format.icon_set.is_none() { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: action.to_string(), + message: "icon set settings are required".to_string(), + }); + } + } + SpreadsheetConditionalFormatType::Top10 => { + if format.rank.is_none() { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: action.to_string(), + message: "top10 rank is required".to_string(), + }); + } + } + SpreadsheetConditionalFormatType::TimePeriod => { + if format.time_period.as_deref().unwrap_or_default().is_empty() { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: action.to_string(), + message: "time period is required".to_string(), + }); + } + } + SpreadsheetConditionalFormatType::AboveAverage => {} + SpreadsheetConditionalFormatType::UniqueValues + | SpreadsheetConditionalFormatType::DuplicateValues + | SpreadsheetConditionalFormatType::ContainsBlanks + | SpreadsheetConditionalFormatType::NotContainsBlanks + | SpreadsheetConditionalFormatType::ContainsErrors + | SpreadsheetConditionalFormatType::NotContainsErrors => {} + } + + if !has_style && !has_intrinsic_visual { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: action.to_string(), + message: "conditional formatting requires at least one style component".to_string(), + }); + } + Ok(()) +} diff --git a/codex-rs/artifact-spreadsheet/src/error.rs b/codex-rs/artifact-spreadsheet/src/error.rs new file mode 100644 index 0000000000..d6d0b31bc5 --- /dev/null +++ b/codex-rs/artifact-spreadsheet/src/error.rs @@ -0,0 +1,39 @@ +use std::path::PathBuf; + +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum SpreadsheetArtifactError { + #[error("missing `artifact_id` for action `{action}`")] + MissingArtifactId { action: String }, + #[error("unknown artifact id `{artifact_id}` for action `{action}`")] + UnknownArtifactId { action: String, artifact_id: String }, + #[error("unknown action `{0}`")] + UnknownAction(String), + #[error("invalid args for action `{action}`: {message}")] + InvalidArgs { action: String, message: String }, + #[error("invalid address `{address}`: {message}")] + InvalidAddress { address: String, message: String }, + #[error("sheet lookup failed for action `{action}`: {message}")] + SheetLookup { action: String, message: String }, + #[error("index `{index}` is out of range for action `{action}`; len={len}")] + IndexOutOfRange { + action: String, + index: usize, + len: usize, + }, + #[error("merge conflict for action `{action}` on range `{range}` with `{conflict}`")] + MergeConflict { + action: String, + range: String, + conflict: String, + }, + #[error("formula error at `{location}`: {message}")] + Formula { location: String, message: String }, + #[error("serialization failed: {message}")] + Serialization { message: String }, + #[error("failed to import XLSX `{path}`: {message}")] + ImportFailed { path: PathBuf, message: String }, + #[error("failed to export XLSX `{path}`: {message}")] + ExportFailed { path: PathBuf, message: String }, +} diff --git a/codex-rs/artifact-spreadsheet/src/formula.rs b/codex-rs/artifact-spreadsheet/src/formula.rs new file mode 100644 index 0000000000..9ee8d0824f --- /dev/null +++ b/codex-rs/artifact-spreadsheet/src/formula.rs @@ -0,0 +1,535 @@ +use std::collections::BTreeSet; + +use crate::CellAddress; +use crate::CellRange; +use crate::SpreadsheetArtifact; +use crate::SpreadsheetArtifactError; +use crate::SpreadsheetCellValue; + +#[derive(Debug, Clone)] +enum Token { + Number(f64), + Cell(String), + Ident(String), + Plus, + Minus, + Star, + Slash, + LParen, + RParen, + Colon, + Comma, +} + +#[derive(Debug, Clone)] +enum Expr { + Number(f64), + Cell(CellAddress), + Range(CellRange), + UnaryMinus(Box), + Binary { + op: BinaryOp, + left: Box, + right: Box, + }, + Function { + name: String, + args: Vec, + }, +} + +#[derive(Debug, Clone, Copy)] +enum BinaryOp { + Add, + Subtract, + Multiply, + Divide, +} + +#[derive(Debug, Clone)] +enum EvalValue { + Scalar(Option), + Range(Vec>), +} + +pub(crate) fn recalculate_workbook(artifact: &mut SpreadsheetArtifact) { + let updates = artifact + .sheets + .iter() + .enumerate() + .flat_map(|(sheet_index, sheet)| { + sheet.cells.iter().filter_map(move |(address, cell)| { + cell.formula + .as_ref() + .map(|formula| (sheet_index, *address, formula.clone())) + }) + }) + .map(|(sheet_index, address, formula)| { + let mut stack = BTreeSet::new(); + let value = evaluate_formula(artifact, sheet_index, &formula, &mut stack) + .unwrap_or_else(|error| { + Some(SpreadsheetCellValue::Error(map_error_to_code(&error))) + }); + (sheet_index, address, value) + }) + .collect::>(); + + for (sheet_index, address, value) in updates { + if let Some(sheet) = artifact.sheets.get_mut(sheet_index) + && let Some(cell) = sheet.cells.get_mut(&address) + { + cell.value = value; + } + } +} + +fn evaluate_formula( + artifact: &SpreadsheetArtifact, + sheet_index: usize, + formula: &str, + stack: &mut BTreeSet<(usize, CellAddress)>, +) -> Result, SpreadsheetArtifactError> { + let source = formula.trim().trim_start_matches('='); + let tokens = tokenize(source)?; + let mut parser = Parser::new(tokens); + let expr = parser.parse_expression()?; + if parser.has_remaining() { + return Err(SpreadsheetArtifactError::Formula { + location: formula.to_string(), + message: "unexpected trailing tokens".to_string(), + }); + } + match evaluate_expr(artifact, sheet_index, &expr, stack)? { + EvalValue::Scalar(value) => Ok(value), + EvalValue::Range(_) => Err(SpreadsheetArtifactError::Formula { + location: formula.to_string(), + message: "range expressions are only allowed inside functions".to_string(), + }), + } +} + +fn evaluate_expr( + artifact: &SpreadsheetArtifact, + sheet_index: usize, + expr: &Expr, + stack: &mut BTreeSet<(usize, CellAddress)>, +) -> Result { + match expr { + Expr::Number(value) => Ok(EvalValue::Scalar(Some(number_to_value(*value)))), + Expr::Cell(address) => evaluate_cell_reference(artifact, sheet_index, *address, stack), + Expr::Range(range) => { + let sheet = artifact.sheets.get(sheet_index).ok_or_else(|| { + SpreadsheetArtifactError::Formula { + location: range.to_a1(), + message: "sheet index was not found".to_string(), + } + })?; + let values = range + .addresses() + .map(|address| sheet.get_cell(address).and_then(|cell| cell.value.clone())) + .collect::>(); + Ok(EvalValue::Range(values)) + } + Expr::UnaryMinus(inner) => { + let value = evaluate_scalar(artifact, sheet_index, inner, stack)?; + Ok(EvalValue::Scalar(match value { + None => Some(SpreadsheetCellValue::Integer(0)), + Some(SpreadsheetCellValue::Integer(value)) => { + Some(SpreadsheetCellValue::Integer(-value)) + } + Some(SpreadsheetCellValue::Float(value)) => { + Some(SpreadsheetCellValue::Float(-value)) + } + Some(SpreadsheetCellValue::Error(value)) => { + Some(SpreadsheetCellValue::Error(value)) + } + Some(_) => Some(SpreadsheetCellValue::Error("#VALUE!".to_string())), + })) + } + Expr::Binary { op, left, right } => { + let left = evaluate_scalar(artifact, sheet_index, left, stack)?; + let right = evaluate_scalar(artifact, sheet_index, right, stack)?; + Ok(EvalValue::Scalar(Some(apply_binary_op(*op, left, right)?))) + } + Expr::Function { name, args } => { + let mut numeric = Vec::new(); + for arg in args { + match evaluate_expr(artifact, sheet_index, arg, stack)? { + EvalValue::Scalar(value) => { + if let Some(number) = scalar_to_number(value.clone())? { + numeric.push(number); + } + } + EvalValue::Range(values) => { + for value in values { + if let Some(number) = scalar_to_number(value.clone())? { + numeric.push(number); + } + } + } + } + } + let upper = name.to_ascii_uppercase(); + let result = match upper.as_str() { + "SUM" => numeric.iter().sum::(), + "AVERAGE" => { + if numeric.is_empty() { + return Ok(EvalValue::Scalar(None)); + } + numeric.iter().sum::() / numeric.len() as f64 + } + "MIN" => numeric.iter().copied().reduce(f64::min).unwrap_or(0.0), + "MAX" => numeric.iter().copied().reduce(f64::max).unwrap_or(0.0), + _ => { + return Ok(EvalValue::Scalar(Some(SpreadsheetCellValue::Error( + "#NAME?".to_string(), + )))); + } + }; + Ok(EvalValue::Scalar(Some(number_to_value(result)))) + } + } +} + +fn evaluate_scalar( + artifact: &SpreadsheetArtifact, + sheet_index: usize, + expr: &Expr, + stack: &mut BTreeSet<(usize, CellAddress)>, +) -> Result, SpreadsheetArtifactError> { + match evaluate_expr(artifact, sheet_index, expr, stack)? { + EvalValue::Scalar(value) => Ok(value), + EvalValue::Range(_) => Err(SpreadsheetArtifactError::Formula { + location: format!("{expr:?}"), + message: "expected a scalar expression".to_string(), + }), + } +} + +fn evaluate_cell_reference( + artifact: &SpreadsheetArtifact, + sheet_index: usize, + address: CellAddress, + stack: &mut BTreeSet<(usize, CellAddress)>, +) -> Result { + let Some(sheet) = artifact.sheets.get(sheet_index) else { + return Err(SpreadsheetArtifactError::Formula { + location: address.to_a1(), + message: "sheet index was not found".to_string(), + }); + }; + let key = (sheet_index, address); + if !stack.insert(key) { + return Ok(EvalValue::Scalar(Some(SpreadsheetCellValue::Error( + "#CYCLE!".to_string(), + )))); + } + + let value = if let Some(cell) = sheet.get_cell(address) { + if let Some(formula) = &cell.formula { + evaluate_formula(artifact, sheet_index, formula, stack)? + } else { + cell.value.clone() + } + } else { + None + }; + stack.remove(&key); + Ok(EvalValue::Scalar(value)) +} + +fn apply_binary_op( + op: BinaryOp, + left: Option, + right: Option, +) -> Result { + if let Some(SpreadsheetCellValue::Error(value)) = &left { + return Ok(SpreadsheetCellValue::Error(value.clone())); + } + if let Some(SpreadsheetCellValue::Error(value)) = &right { + return Ok(SpreadsheetCellValue::Error(value.clone())); + } + + let left = scalar_to_number(left)?; + let right = scalar_to_number(right)?; + let left = left.unwrap_or(0.0); + let right = right.unwrap_or(0.0); + let result = match op { + BinaryOp::Add => left + right, + BinaryOp::Subtract => left - right, + BinaryOp::Multiply => left * right, + BinaryOp::Divide => { + if right == 0.0 { + return Ok(SpreadsheetCellValue::Error("#DIV/0!".to_string())); + } + left / right + } + }; + Ok(number_to_value(result)) +} + +fn scalar_to_number( + value: Option, +) -> Result, SpreadsheetArtifactError> { + match value { + None => Ok(None), + Some(SpreadsheetCellValue::Integer(value)) => Ok(Some(value as f64)), + Some(SpreadsheetCellValue::Float(value)) => Ok(Some(value)), + Some(SpreadsheetCellValue::Bool(value)) => Ok(Some(if value { 1.0 } else { 0.0 })), + Some(SpreadsheetCellValue::Error(value)) => Err(SpreadsheetArtifactError::Formula { + location: value, + message: "encountered error value".to_string(), + }), + Some(other) => Err(SpreadsheetArtifactError::Formula { + location: format!("{other:?}"), + message: "value is not numeric".to_string(), + }), + } +} + +fn number_to_value(number: f64) -> SpreadsheetCellValue { + if number.fract() == 0.0 { + SpreadsheetCellValue::Integer(number as i64) + } else { + SpreadsheetCellValue::Float(number) + } +} + +fn map_error_to_code(error: &SpreadsheetArtifactError) -> String { + match error { + SpreadsheetArtifactError::Formula { message, .. } => { + if message.contains("cycle") { + "#CYCLE!".to_string() + } else if message.contains("not numeric") || message.contains("scalar") { + "#VALUE!".to_string() + } else { + "#ERROR!".to_string() + } + } + SpreadsheetArtifactError::InvalidAddress { .. } => "#REF!".to_string(), + _ => "#ERROR!".to_string(), + } +} + +fn tokenize(source: &str) -> Result, SpreadsheetArtifactError> { + let chars = source.chars().collect::>(); + let mut index = 0usize; + let mut tokens = Vec::new(); + while index < chars.len() { + let ch = chars[index]; + if ch.is_ascii_whitespace() { + index += 1; + continue; + } + match ch { + '+' => { + tokens.push(Token::Plus); + index += 1; + } + '-' => { + tokens.push(Token::Minus); + index += 1; + } + '*' => { + tokens.push(Token::Star); + index += 1; + } + '/' => { + tokens.push(Token::Slash); + index += 1; + } + '(' => { + tokens.push(Token::LParen); + index += 1; + } + ')' => { + tokens.push(Token::RParen); + index += 1; + } + ':' => { + tokens.push(Token::Colon); + index += 1; + } + ',' => { + tokens.push(Token::Comma); + index += 1; + } + '0'..='9' | '.' => { + let start = index; + index += 1; + while index < chars.len() && (chars[index].is_ascii_digit() || chars[index] == '.') + { + index += 1; + } + let number = source[start..index].parse::().map_err(|_| { + SpreadsheetArtifactError::Formula { + location: source.to_string(), + message: "invalid numeric literal".to_string(), + } + })?; + tokens.push(Token::Number(number)); + } + 'A'..='Z' | 'a'..='z' | '_' => { + let start = index; + index += 1; + while index < chars.len() + && (chars[index].is_ascii_alphanumeric() || chars[index] == '_') + { + index += 1; + } + let text = source[start..index].to_string(); + if text.chars().any(|part| part.is_ascii_digit()) + && text.chars().any(|part| part.is_ascii_alphabetic()) + { + tokens.push(Token::Cell(text)); + } else { + tokens.push(Token::Ident(text)); + } + } + other => { + return Err(SpreadsheetArtifactError::Formula { + location: source.to_string(), + message: format!("unsupported token `{other}`"), + }); + } + } + } + Ok(tokens) +} + +struct Parser { + tokens: Vec, + index: usize, +} + +impl Parser { + fn new(tokens: Vec) -> Self { + Self { tokens, index: 0 } + } + + fn has_remaining(&self) -> bool { + self.index < self.tokens.len() + } + + fn parse_expression(&mut self) -> Result { + let mut expr = self.parse_term()?; + while let Some(token) = self.peek() { + let op = match token { + Token::Plus => BinaryOp::Add, + Token::Minus => BinaryOp::Subtract, + _ => break, + }; + self.index += 1; + let right = self.parse_term()?; + expr = Expr::Binary { + op, + left: Box::new(expr), + right: Box::new(right), + }; + } + Ok(expr) + } + + fn parse_term(&mut self) -> Result { + let mut expr = self.parse_factor()?; + while let Some(token) = self.peek() { + let op = match token { + Token::Star => BinaryOp::Multiply, + Token::Slash => BinaryOp::Divide, + _ => break, + }; + self.index += 1; + let right = self.parse_factor()?; + expr = Expr::Binary { + op, + left: Box::new(expr), + right: Box::new(right), + }; + } + Ok(expr) + } + + fn parse_factor(&mut self) -> Result { + match self.peek() { + Some(Token::Minus) => { + self.index += 1; + Ok(Expr::UnaryMinus(Box::new(self.parse_factor()?))) + } + _ => self.parse_primary(), + } + } + + fn parse_primary(&mut self) -> Result { + match self.next().cloned() { + Some(Token::Number(value)) => Ok(Expr::Number(value)), + Some(Token::Cell(address)) => { + let start = CellAddress::parse(&address)?; + if matches!(self.peek(), Some(Token::Colon)) { + self.index += 1; + let Some(Token::Cell(end)) = self.next().cloned() else { + return Err(SpreadsheetArtifactError::Formula { + location: address, + message: "expected cell after `:`".to_string(), + }); + }; + Ok(Expr::Range(CellRange::from_start_end( + start, + CellAddress::parse(&end)?, + ))) + } else { + Ok(Expr::Cell(start)) + } + } + Some(Token::Ident(name)) => { + if !matches!(self.next(), Some(Token::LParen)) { + return Err(SpreadsheetArtifactError::Formula { + location: name, + message: "expected `(` after function name".to_string(), + }); + } + let mut args = Vec::new(); + if !matches!(self.peek(), Some(Token::RParen)) { + loop { + args.push(self.parse_expression()?); + if matches!(self.peek(), Some(Token::Comma)) { + self.index += 1; + continue; + } + break; + } + } + if !matches!(self.next(), Some(Token::RParen)) { + return Err(SpreadsheetArtifactError::Formula { + location: name, + message: "expected `)`".to_string(), + }); + } + Ok(Expr::Function { name, args }) + } + Some(Token::LParen) => { + let expr = self.parse_expression()?; + if !matches!(self.next(), Some(Token::RParen)) { + return Err(SpreadsheetArtifactError::Formula { + location: format!("{expr:?}"), + message: "expected `)`".to_string(), + }); + } + Ok(expr) + } + other => Err(SpreadsheetArtifactError::Formula { + location: format!("{other:?}"), + message: "unexpected token".to_string(), + }), + } + } + + fn peek(&self) -> Option<&Token> { + self.tokens.get(self.index) + } + + fn next(&mut self) -> Option<&Token> { + let token = self.tokens.get(self.index); + self.index += usize::from(token.is_some()); + token + } +} diff --git a/codex-rs/artifact-spreadsheet/src/lib.rs b/codex-rs/artifact-spreadsheet/src/lib.rs new file mode 100644 index 0000000000..bd9f6ed8fb --- /dev/null +++ b/codex-rs/artifact-spreadsheet/src/lib.rs @@ -0,0 +1,26 @@ +mod address; +mod chart; +mod conditional; +mod error; +mod formula; +mod manager; +mod model; +mod pivot; +mod render; +mod style; +mod table; +mod xlsx; + +#[cfg(test)] +mod tests; + +pub use address::*; +pub use chart::*; +pub use conditional::*; +pub use error::*; +pub use manager::*; +pub use model::*; +pub use pivot::*; +pub use render::*; +pub use style::*; +pub use table::*; diff --git a/codex-rs/artifact-spreadsheet/src/manager.rs b/codex-rs/artifact-spreadsheet/src/manager.rs new file mode 100644 index 0000000000..392b685c48 --- /dev/null +++ b/codex-rs/artifact-spreadsheet/src/manager.rs @@ -0,0 +1,3346 @@ +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::path::Path; +use std::path::PathBuf; + +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value; + +use crate::CellAddress; +use crate::CellRange; +use crate::SpreadsheetArtifact; +use crate::SpreadsheetArtifactError; +use crate::SpreadsheetCellRangeRef; +use crate::SpreadsheetCellRef; +use crate::SpreadsheetCellValue; +use crate::SpreadsheetCitation; +use crate::SpreadsheetRangeView; +use crate::SpreadsheetSheetSummary; +use crate::SpreadsheetSummary; + +#[derive(Debug, Clone, Deserialize)] +pub struct SpreadsheetArtifactRequest { + pub artifact_id: Option, + pub action: String, + #[serde(default)] + pub args: Value, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PathAccessKind { + Read, + Write, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PathAccessRequirement { + pub action: String, + pub kind: PathAccessKind, + pub path: PathBuf, +} + +impl SpreadsheetArtifactRequest { + pub fn required_path_accesses( + &self, + cwd: &Path, + ) -> Result, SpreadsheetArtifactError> { + let access = match self.action.as_str() { + "import_xlsx" | "load" | "read" => { + let args: PathArgs = parse_args(&self.action, &self.args)?; + vec![PathAccessRequirement { + action: self.action.clone(), + kind: PathAccessKind::Read, + path: resolve_path(cwd, &args.path), + }] + } + "export_xlsx" => { + let args: PathArgs = parse_args(&self.action, &self.args)?; + vec![PathAccessRequirement { + action: self.action.clone(), + kind: PathAccessKind::Write, + path: resolve_path(cwd, &args.path), + }] + } + "save" => { + let args: SaveArgs = parse_args(&self.action, &self.args)?; + vec![PathAccessRequirement { + action: self.action.clone(), + kind: PathAccessKind::Write, + path: resolve_path(cwd, &args.path), + }] + } + "render_workbook" | "render_sheet" | "render_range" => { + let args: RenderArgs = parse_args(&self.action, &self.args)?; + args.output_path + .map(|path| { + vec![PathAccessRequirement { + action: self.action.clone(), + kind: PathAccessKind::Write, + path: resolve_path(cwd, &path), + }] + }) + .unwrap_or_default() + } + _ => Vec::new(), + }; + Ok(access) + } +} + +#[derive(Debug, Default)] +pub struct SpreadsheetArtifactManager { + documents: HashMap, +} + +impl SpreadsheetArtifactManager { + pub fn execute( + &mut self, + request: SpreadsheetArtifactRequest, + cwd: &Path, + ) -> Result { + match request.action.as_str() { + "create" => self.create(request), + "import_xlsx" | "load" | "read" => self.import_xlsx(request, cwd), + "export_xlsx" => self.export_xlsx(request, cwd), + "save" => self.save(request, cwd), + "render_workbook" => self.render_workbook(request, cwd), + "render_sheet" => self.render_sheet(request, cwd), + "render_range" => self.render_range(request, cwd), + "get_summary" => self.get_summary(request), + "list_sheets" => self.list_sheets(request), + "get_sheet" => self.get_sheet(request), + "inspect" => self.inspect(request), + "list_charts" => self.list_charts(request), + "get_chart" => self.get_chart(request), + "create_chart" => self.create_chart(request), + "add_chart_series" => self.add_chart_series(request), + "set_chart_properties" => self.set_chart_properties(request), + "delete_chart" => self.delete_chart(request), + "list_tables" => self.list_tables(request), + "get_table" => self.get_table(request), + "create_table" => self.create_table(request), + "set_table_style" => self.set_table_style(request), + "clear_table_filters" => self.clear_table_filters(request), + "reapply_table_filters" => self.reapply_table_filters(request), + "rename_table_column" => self.rename_table_column(request), + "set_table_column_totals" => self.set_table_column_totals(request), + "delete_table" => self.delete_table(request), + "list_conditional_formats" => self.list_conditional_formats(request), + "add_conditional_format" => self.add_conditional_format(request), + "delete_conditional_format" => self.delete_conditional_format(request), + "list_pivot_tables" => self.list_pivot_tables(request), + "get_pivot_table" => self.get_pivot_table(request), + "create_sheet" => self.create_sheet(request), + "rename_sheet" => self.rename_sheet(request), + "delete_sheet" => self.delete_sheet(request), + "set_sheet_properties" => self.set_sheet_properties(request), + "set_column_widths" => self.set_column_widths(request), + "set_column_widths_bulk" => self.set_column_widths_bulk(request), + "set_row_height" => self.set_row_height(request), + "set_row_heights" => self.set_row_heights(request), + "set_row_heights_bulk" => self.set_row_heights_bulk(request), + "get_row_height" => self.get_row_height(request), + "cleanup_and_validate_sheet" => self.cleanup_and_validate_sheet(request), + "create_text_style" => self.create_text_style(request), + "get_text_style" => self.get_text_style(request), + "create_fill" => self.create_fill(request), + "get_fill" => self.get_fill(request), + "create_border" => self.create_border(request), + "get_border" => self.get_border(request), + "create_number_format" => self.create_number_format(request), + "get_number_format" => self.get_number_format(request), + "create_cell_format" => self.create_cell_format(request), + "get_cell_format" => self.get_cell_format(request), + "create_differential_format" => self.create_differential_format(request), + "get_differential_format" => self.get_differential_format(request), + "get_cell_format_summary" => self.get_cell_format_summary(request), + "get_range_format_summary" => self.get_range_format_summary(request), + "get_reference" => self.get_reference(request), + "get_cell" => self.get_cell(request), + "get_cell_by_indices" => self.get_cell_by_indices(request), + "get_cell_field" => self.get_cell_field(request), + "get_cell_field_by_indices" => self.get_cell_field_by_indices(request), + "get_range" => self.get_range(request), + "set_cell_value" => self.set_cell_value(request), + "set_range_value" => self.set_range_value(request), + "set_range_values" => self.set_range_values(request), + "set_cell_formula" => self.set_cell_formula(request), + "set_range_formula" => self.set_range_formula(request), + "set_range_formulas" => self.set_range_formulas(request), + "set_cell_style" => self.set_cell_style(request), + "set_range_style" => self.set_range_style(request), + "clear_range" => self.clear_range(request), + "merge_range" => self.merge_range(request), + "unmerge_range" => self.unmerge_range(request), + "cite_cell" => self.cite_cell(request), + "cite_range" => self.cite_range(request), + "calculate" | "recalculate" => self.calculate(request), + "serialize_dict" => self.serialize_dict(request), + "serialize_json" => self.serialize_json(request), + "serialize_bytes" => self.serialize_bytes(request), + "deserialize_dict" => self.deserialize_dict(request), + "deserialize_json" => self.deserialize_json(request), + "deserialize_bytes" => self.deserialize_bytes(request), + "delete_artifact" => self.delete_artifact(request), + other => Err(SpreadsheetArtifactError::UnknownAction(other.to_string())), + } + } + + fn create( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: CreateArgs = parse_args(&request.action, &request.args)?; + let mut artifact = SpreadsheetArtifact::new(args.name); + if let Some(auto_recalculate) = args.auto_recalculate { + artifact.auto_recalculate = auto_recalculate; + } + let artifact_id = artifact.artifact_id.clone(); + let summary = format!("Created spreadsheet artifact `{artifact_id}`"); + let snapshot = snapshot_for_artifact(&artifact); + self.documents.insert(artifact_id.clone(), artifact); + Ok(SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + summary, + snapshot, + )) + } + + fn import_xlsx( + &mut self, + request: SpreadsheetArtifactRequest, + cwd: &Path, + ) -> Result { + let args: PathArgs = parse_args(&request.action, &request.args)?; + let path = resolve_path(cwd, &args.path); + let artifact = SpreadsheetArtifact::from_source_file(&path, None)?; + let artifact_id = artifact.artifact_id.clone(); + let snapshot = snapshot_for_artifact(&artifact); + let summary = format!( + "Imported `{}` as spreadsheet artifact `{artifact_id}` with {} sheets", + path.display(), + artifact.sheets.len() + ); + self.documents.insert(artifact_id.clone(), artifact); + Ok(SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + summary, + snapshot, + )) + } + + fn export_xlsx( + &mut self, + request: SpreadsheetArtifactRequest, + cwd: &Path, + ) -> Result { + let args: PathArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let path = resolve_path(cwd, &args.path); + let exported = artifact.export(&path)?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Exported spreadsheet to `{}`", exported.display()), + snapshot_for_artifact(artifact), + ); + response.exported_paths.push(exported); + response.workbook_summary = Some(artifact.summary()); + response.sheet_list = Some( + artifact + .sheets + .iter() + .map(super::model::SpreadsheetSheet::summary) + .collect(), + ); + Ok(response) + } + + fn save( + &mut self, + request: SpreadsheetArtifactRequest, + cwd: &Path, + ) -> Result { + let args: SaveArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let path = resolve_path(cwd, &args.path); + let exported = artifact.save(&path, args.file_type.as_deref())?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Saved spreadsheet to `{}`", exported.display()), + snapshot_for_artifact(artifact), + ); + response.exported_paths.push(exported); + response.workbook_summary = Some(artifact.summary()); + Ok(response) + } + + fn get_summary( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!( + "Spreadsheet has {} sheets and {} bytes of serialized state", + artifact.sheets.len(), + artifact.summary().size_bytes + ), + snapshot_for_artifact(artifact), + ); + response.workbook_summary = Some(artifact.summary()); + response.sheet_list = Some( + artifact + .sheets + .iter() + .map(super::model::SpreadsheetSheet::summary) + .collect(), + ); + Ok(response) + } + + fn render_workbook( + &mut self, + request: SpreadsheetArtifactRequest, + cwd: &Path, + ) -> Result { + let args: RenderArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let rendered = artifact.render_workbook_previews(cwd, &render_options_from_args(args)?)?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Rendered workbook to {} preview files", rendered.len()), + snapshot_for_artifact(artifact), + ); + response.exported_paths = rendered.into_iter().map(|output| output.path).collect(); + Ok(response) + } + + fn render_sheet( + &mut self, + request: SpreadsheetArtifactRequest, + cwd: &Path, + ) -> Result { + let args: RenderArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let sheet = artifact.sheet_lookup( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let rendered = + artifact.render_sheet_preview(cwd, sheet, &render_options_from_args(args)?)?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Rendered sheet `{}`", sheet.name), + snapshot_for_artifact(artifact), + ); + response.sheet_ref = Some(sheet_reference(sheet)); + response.exported_paths.push(rendered.path); + response.rendered_html = Some(rendered.html); + response.rendered_text = Some(sheet.to_rendered_text(None)); + Ok(response) + } + + fn render_range( + &mut self, + request: SpreadsheetArtifactRequest, + cwd: &Path, + ) -> Result { + let args: RenderArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let sheet = artifact.sheet_lookup( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let range_text = + args.range + .clone() + .ok_or_else(|| SpreadsheetArtifactError::InvalidArgs { + action: request.action.clone(), + message: "range is required".to_string(), + })?; + let range = CellRange::parse(&range_text)?; + let rendered = + artifact.render_range_preview(cwd, sheet, &range, &render_options_from_args(args)?)?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Rendered range `{range_text}` from `{}`", sheet.name), + snapshot_for_artifact(artifact), + ); + response.sheet_ref = Some(sheet_reference(sheet)); + response.range_ref = Some(SpreadsheetCellRangeRef::new(sheet.name.clone(), &range)); + response.exported_paths.push(rendered.path); + response.rendered_html = Some(rendered.html); + response.rendered_text = Some(sheet.to_rendered_text(Some(&range))); + Ok(response) + } + + fn list_sheets( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Listed {} sheets", artifact.sheets.len()), + snapshot_for_artifact(artifact), + ); + response.sheet_list = Some( + artifact + .sheets + .iter() + .map(super::model::SpreadsheetSheet::summary) + .collect(), + ); + Ok(response) + } + + fn get_sheet( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: SheetLookupArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let sheet = artifact.sheet_lookup( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let range = if let Some(range) = args.range.as_deref() { + Some(CellRange::parse(range)?) + } else { + sheet.minimum_range() + }; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Retrieved sheet `{}`", sheet.name), + snapshot_for_artifact(artifact), + ); + response.sheet_list = Some(vec![sheet.summary()]); + response.sheet_ref = Some(sheet_reference(sheet)); + response.range_ref = range + .as_ref() + .map(|entry| SpreadsheetCellRangeRef::new(sheet.name.clone(), entry)); + response.rendered_text = Some(sheet.to_rendered_text(range.as_ref())); + response.range = range.as_ref().map(|entry| sheet.get_range_view(entry)); + response.top_left_style_index = range + .as_ref() + .map(|entry| sheet.top_left_style_index(entry)); + response.range_format = range.as_ref().map(|entry| sheet.range_format(entry)); + response.cell_format_summary = response + .top_left_style_index + .and_then(|style_index| artifact.cell_format_summary(style_index)); + response.serialized_dict = Some(sheet.to_dict()?); + Ok(response) + } + + fn inspect( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: SheetLookupArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let inspected = if args.sheet_name.is_none() && args.sheet_index.is_none() { + artifact.to_dict()? + } else { + let sheet = artifact.sheet_lookup( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + serde_json::to_value(sheet).map_err(|error| { + SpreadsheetArtifactError::Serialization { + message: error.to_string(), + } + })? + }; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + "Generated inspection snapshot".to_string(), + snapshot_for_artifact(artifact), + ); + response.serialized_dict = Some(inspected); + Ok(response) + } + + fn create_sheet( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: CreateSheetArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let name = args.name.clone(); + artifact.create_sheet(args.name)?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Created sheet `{name}`"), + snapshot_for_artifact(artifact), + ); + response.sheet_list = Some( + artifact + .sheets + .iter() + .map(super::model::SpreadsheetSheet::summary) + .collect(), + ); + Ok(response) + } + + fn list_charts( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: SheetLookupArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let sheet = artifact.sheet_lookup( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let range = args.range.as_deref().map(CellRange::parse).transpose()?; + let charts = sheet.list_charts(range.as_ref())?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Listed {} charts on `{}`", charts.len(), sheet.name), + snapshot_for_artifact(artifact), + ); + response.sheet_ref = Some(sheet_reference(sheet)); + response.range_ref = range + .as_ref() + .map(|entry| SpreadsheetCellRangeRef::new(sheet.name.clone(), entry)); + response.chart_list = Some(charts); + Ok(response) + } + + fn get_chart( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: ChartLookupArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let sheet = artifact.sheet_lookup( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let chart = sheet + .get_chart(&request.action, chart_lookup_from_args(&args))? + .clone(); + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Retrieved chart `{}` from `{}`", chart.id, sheet.name), + snapshot_for_artifact(artifact), + ); + response.sheet_ref = Some(sheet_reference(sheet)); + response.chart_list = Some(vec![chart]); + Ok(response) + } + + fn create_chart( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: CreateChartArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + if let Some(source_sheet_name) = args.source_sheet_name.as_deref() { + artifact.sheet_lookup(&request.action, Some(source_sheet_name), None)?; + } + let source_range = CellRange::parse(&args.source_range)?; + let chart = { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let chart_id = sheet.create_chart( + &request.action, + args.chart_type, + args.source_sheet_name.or_else(|| Some(sheet.name.clone())), + &source_range, + args.options.unwrap_or_default(), + )?; + sheet + .get_chart( + &request.action, + crate::SpreadsheetChartLookup { + id: Some(chart_id), + index: None, + }, + )? + .clone() + }; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Created chart `{}`", chart.id), + snapshot_for_artifact(artifact), + ); + response.chart_list = Some(vec![chart]); + Ok(response) + } + + fn add_chart_series( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: AddChartSeriesArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let chart = { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let series_id = sheet.add_chart_series( + &request.action, + crate::SpreadsheetChartLookup { + id: args.chart_id, + index: args.chart_index.map(|value| value as usize), + }, + args.series, + )?; + let chart = sheet.get_chart( + &request.action, + crate::SpreadsheetChartLookup { + id: args.chart_id.or(Some(series_id).and(None)), + index: args.chart_index.map(|value| value as usize), + }, + ); + match chart { + Ok(chart) => chart.clone(), + Err(_) => sheet + .get_chart( + &request.action, + crate::SpreadsheetChartLookup { + id: None, + index: args.chart_index.map(|value| value as usize), + }, + )? + .clone(), + } + }; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Added chart series to chart `{}`", chart.id), + snapshot_for_artifact(artifact), + ); + response.chart_list = Some(vec![chart]); + Ok(response) + } + + fn set_chart_properties( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: SetChartPropertiesArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let chart = { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.lookup.sheet_name.as_deref(), + args.lookup.sheet_index.map(|value| value as usize), + )?; + let lookup = chart_lookup_from_args(&args.lookup); + sheet.set_chart_properties(&request.action, lookup.clone(), args.properties)?; + sheet.get_chart(&request.action, lookup)?.clone() + }; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Updated chart `{}`", chart.id), + snapshot_for_artifact(artifact), + ); + response.chart_list = Some(vec![chart]); + Ok(response) + } + + fn delete_chart( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: ChartLookupArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let sheet_name = { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let name = sheet.name.clone(); + sheet.delete_chart(&request.action, chart_lookup_from_args(&args))?; + name + }; + let action = request.action.clone(); + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + action.clone(), + format!("Deleted chart from `{sheet_name}`"), + snapshot_for_artifact(artifact), + ); + response.sheet_list = Some(vec![ + artifact + .sheet_lookup(&action, Some(&sheet_name), None)? + .summary(), + ]); + Ok(response) + } + + fn list_tables( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: SheetLookupArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let sheet = artifact.sheet_lookup( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let range = args.range.as_deref().map(CellRange::parse).transpose()?; + let tables = sheet.list_tables(range.as_ref())?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Listed {} tables on `{}`", tables.len(), sheet.name), + snapshot_for_artifact(artifact), + ); + response.sheet_ref = Some(sheet_reference(sheet)); + response.range_ref = range + .as_ref() + .map(|entry| SpreadsheetCellRangeRef::new(sheet.name.clone(), entry)); + response.table_list = Some(tables); + Ok(response) + } + + fn get_table( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: TableLookupArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let sheet = artifact.sheet_lookup( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let table = sheet.get_table_view(&request.action, table_lookup_from_args(&args))?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Retrieved table `{}` from `{}`", table.name, sheet.name), + snapshot_for_artifact(artifact), + ); + response.sheet_ref = Some(sheet_reference(sheet)); + response.table_list = Some(vec![table]); + Ok(response) + } + + fn create_table( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: CreateTableArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let range = CellRange::parse(&args.range)?; + let table = { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let table_id = sheet.create_table(&request.action, &range, args.options)?; + sheet.get_table_view( + &request.action, + crate::SpreadsheetTableLookup { + name: None, + display_name: None, + id: Some(table_id), + }, + )? + }; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Created table `{}`", table.name), + snapshot_for_artifact(artifact), + ); + response.table_list = Some(vec![table]); + Ok(response) + } + + fn set_table_style( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: SetTableStyleArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let table = { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.lookup.sheet_name.as_deref(), + args.lookup.sheet_index.map(|value| value as usize), + )?; + let lookup = table_lookup_from_args(&args.lookup); + sheet.set_table_style(&request.action, lookup.clone(), args.options)?; + sheet.get_table_view(&request.action, lookup)? + }; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Updated table style for `{}`", table.name), + snapshot_for_artifact(artifact), + ); + response.table_list = Some(vec![table]); + Ok(response) + } + + fn clear_table_filters( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: TableLookupArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let table = { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let lookup = table_lookup_from_args(&args); + sheet.clear_table_filters(&request.action, lookup.clone())?; + sheet.get_table_view(&request.action, lookup)? + }; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Cleared table filters for `{}`", table.name), + snapshot_for_artifact(artifact), + ); + response.table_list = Some(vec![table]); + Ok(response) + } + + fn reapply_table_filters( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: TableLookupArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let table = { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let lookup = table_lookup_from_args(&args); + sheet.reapply_table_filters(&request.action, lookup.clone())?; + sheet.get_table_view(&request.action, lookup)? + }; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Reapplied table filters for `{}`", table.name), + snapshot_for_artifact(artifact), + ); + response.table_list = Some(vec![table]); + Ok(response) + } + + fn rename_table_column( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: RenameTableColumnArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let column = { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.lookup.sheet_name.as_deref(), + args.lookup.sheet_index.map(|value| value as usize), + )?; + sheet.rename_table_column( + &request.action, + table_lookup_from_args(&args.lookup), + args.column_id, + args.column_name.as_deref(), + args.new_name, + )? + }; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Renamed table column to `{}`", column.name), + snapshot_for_artifact(artifact), + ); + response.serialized_dict = Some(to_serialized_value(column)?); + Ok(response) + } + + fn set_table_column_totals( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: SetTableColumnTotalsArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let column = { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.lookup.sheet_name.as_deref(), + args.lookup.sheet_index.map(|value| value as usize), + )?; + sheet.set_table_column_totals( + &request.action, + table_lookup_from_args(&args.lookup), + args.column_id, + args.column_name.as_deref(), + args.totals_row_label, + args.totals_row_function, + )? + }; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Updated totals metadata for column `{}`", column.name), + snapshot_for_artifact(artifact), + ); + response.serialized_dict = Some(to_serialized_value(column)?); + Ok(response) + } + + fn delete_table( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: TableLookupArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let sheet_name = { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let name = sheet.name.clone(); + sheet.delete_table(&request.action, table_lookup_from_args(&args))?; + name + }; + let action = request.action.clone(); + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + action.clone(), + format!("Deleted table from `{sheet_name}`"), + snapshot_for_artifact(artifact), + ); + response.sheet_list = Some(vec![ + artifact + .sheet_lookup(&action, Some(&sheet_name), None)? + .summary(), + ]); + Ok(response) + } + + fn list_conditional_formats( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: SheetLookupArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let sheet = artifact.sheet_lookup( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let range = args.range.as_deref().map(CellRange::parse).transpose()?; + let formats = sheet.list_conditional_formats(range.as_ref()); + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!( + "Listed {} conditional formats on `{}`", + formats.len(), + sheet.name + ), + snapshot_for_artifact(artifact), + ); + response.sheet_ref = Some(sheet_reference(sheet)); + response.range_ref = range + .as_ref() + .map(|entry| SpreadsheetCellRangeRef::new(sheet.name.clone(), entry)); + response.conditional_format_list = Some(formats); + Ok(response) + } + + fn add_conditional_format( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: AddConditionalFormatArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let sheet_name = artifact + .sheet_lookup( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )? + .name + .clone(); + let format_id = + artifact.add_conditional_format(&request.action, &sheet_name, args.format)?; + let format = artifact + .sheet_lookup(&request.action, Some(&sheet_name), None)? + .conditional_formats + .iter() + .find(|entry| entry.id == format_id) + .cloned() + .ok_or_else(|| SpreadsheetArtifactError::Serialization { + message: format!("created conditional format `{format_id}` was not available"), + })?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Created conditional format `{format_id}`"), + snapshot_for_artifact(artifact), + ); + response.conditional_format_list = Some(vec![format]); + Ok(response) + } + + fn delete_conditional_format( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: DeleteConditionalFormatArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let sheet_name = artifact + .sheet_lookup( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )? + .name + .clone(); + artifact.delete_conditional_format(&request.action, &sheet_name, args.id)?; + let action = request.action.clone(); + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + action.clone(), + format!("Deleted conditional format `{}`", args.id), + snapshot_for_artifact(artifact), + ); + response.sheet_list = Some(vec![ + artifact + .sheet_lookup(&action, Some(&sheet_name), None)? + .summary(), + ]); + Ok(response) + } + + fn list_pivot_tables( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: SheetLookupArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let sheet = artifact.sheet_lookup( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let range = args.range.as_deref().map(CellRange::parse).transpose()?; + let pivots = sheet.list_pivot_tables(range.as_ref())?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Listed {} pivot tables on `{}`", pivots.len(), sheet.name), + snapshot_for_artifact(artifact), + ); + response.sheet_ref = Some(sheet_reference(sheet)); + response.range_ref = range + .as_ref() + .map(|entry| SpreadsheetCellRangeRef::new(sheet.name.clone(), entry)); + response.pivot_table_list = Some(pivots); + Ok(response) + } + + fn get_pivot_table( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: PivotTableLookupArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let sheet = artifact.sheet_lookup( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let pivot = sheet + .get_pivot_table(&request.action, pivot_lookup_from_args(&args))? + .clone(); + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!( + "Retrieved pivot table `{}` from `{}`", + pivot.name, sheet.name + ), + snapshot_for_artifact(artifact), + ); + response.sheet_ref = Some(sheet_reference(sheet)); + response.pivot_table_list = Some(vec![pivot]); + Ok(response) + } + + fn rename_sheet( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: RenameSheetArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let new_name = args.new_name.clone(); + artifact.rename_sheet( + args.new_name, + args.old_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Renamed sheet to `{new_name}`"), + snapshot_for_artifact(artifact), + ); + response.sheet_list = Some( + artifact + .sheets + .iter() + .map(super::model::SpreadsheetSheet::summary) + .collect(), + ); + Ok(response) + } + + fn delete_sheet( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: DeleteSheetArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + artifact.delete_sheet( + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + "Deleted sheet".to_string(), + snapshot_for_artifact(artifact), + ); + response.sheet_list = Some( + artifact + .sheets + .iter() + .map(super::model::SpreadsheetSheet::summary) + .collect(), + ); + Ok(response) + } + + fn set_sheet_properties( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: SetSheetPropertiesArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let sheet_summary = { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + if let Some(default_row_height) = args.default_row_height { + sheet.default_row_height = Some(default_row_height); + } + if let Some(default_column_width) = args.default_column_width { + sheet.default_column_width = Some(default_column_width); + } + if let Some(show_grid_lines) = args.show_grid_lines { + sheet.show_grid_lines = show_grid_lines; + } + sheet.summary() + }; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Updated sheet `{}` properties", sheet_summary.name), + snapshot_for_artifact(artifact), + ); + response.sheet_list = Some(vec![sheet_summary]); + Ok(response) + } + + fn set_column_widths( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: SetColumnWidthsArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let sheet_summary = { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + sheet.set_column_widths(&args.reference, args.width)?; + sheet.summary() + }; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!( + "Updated column widths `{}` on `{}`", + args.reference, sheet_summary.name + ), + snapshot_for_artifact(artifact), + ); + response.sheet_list = Some(vec![sheet_summary]); + Ok(response) + } + + fn set_column_widths_bulk( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: SetColumnWidthsBulkArgs = parse_args(&request.action, &request.args)?; + let width_count = args.widths.len(); + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let sheet_summary = { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + sheet.set_column_widths_bulk(&args.widths)?; + sheet.summary() + }; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!( + "Updated {width_count} column width references on `{}`", + sheet_summary.name + ), + snapshot_for_artifact(artifact), + ); + response.sheet_list = Some(vec![sheet_summary]); + Ok(response) + } + + fn set_row_height( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: SetRowHeightArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let (sheet_summary, row_height) = { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + sheet.set_row_height(args.row_index, args.height)?; + (sheet.summary(), sheet.get_row_height(args.row_index)) + }; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!( + "Updated row height for row {} on `{}`", + args.row_index, sheet_summary.name + ), + snapshot_for_artifact(artifact), + ); + response.sheet_list = Some(vec![sheet_summary]); + response.row_height = row_height; + Ok(response) + } + + fn set_row_heights( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: SetRowHeightsArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let start = args.start_row_index.min(args.end_row_index); + let end = args.start_row_index.max(args.end_row_index); + let sheet_summary = { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + sheet.set_row_heights(args.start_row_index, args.end_row_index, args.height)?; + sheet.summary() + }; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!( + "Updated row heights {start}:{end} on `{}`", + sheet_summary.name + ), + snapshot_for_artifact(artifact), + ); + response.sheet_list = Some(vec![sheet_summary]); + Ok(response) + } + + fn set_row_heights_bulk( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: SetRowHeightsBulkArgs = parse_args(&request.action, &request.args)?; + let height_count = args.heights.len(); + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let sheet_summary = { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + sheet.set_row_heights_bulk(&args.heights)?; + sheet.summary() + }; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!( + "Updated {height_count} row height entries on `{}`", + sheet_summary.name + ), + snapshot_for_artifact(artifact), + ); + response.sheet_list = Some(vec![sheet_summary]); + Ok(response) + } + + fn get_row_height( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: GetRowHeightArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let sheet = artifact.sheet_lookup( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!( + "Retrieved row height for row {} from `{}`", + args.row_index, sheet.name + ), + snapshot_for_artifact(artifact), + ); + response.sheet_ref = Some(sheet_reference(sheet)); + response.sheet_list = Some(vec![sheet.summary()]); + response.row_height = sheet.get_row_height(args.row_index); + Ok(response) + } + + fn cleanup_and_validate_sheet( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: SheetLookupArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let sheet_summary = { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + sheet.cleanup_and_validate_sheet()?; + sheet.summary() + }; + artifact.validate_conditional_formats(&request.action, &sheet_summary.name)?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Cleaned and validated `{}`", sheet_summary.name), + snapshot_for_artifact(artifact), + ); + response.sheet_list = Some(vec![sheet_summary]); + Ok(response) + } + + fn create_text_style( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: CreateTextStyleArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let style_id = artifact.create_text_style( + args.style, + args.source_style_id, + args.merge_with_existing_components.unwrap_or(false), + )?; + let style = artifact.get_text_style(style_id).cloned().ok_or_else(|| { + SpreadsheetArtifactError::Serialization { + message: format!("created text style `{style_id}` was not available"), + } + })?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Created text style `{style_id}`"), + snapshot_for_artifact(artifact), + ); + response.style_id = Some(style_id); + response.serialized_dict = Some(to_serialized_value(style)?); + Ok(response) + } + + fn get_text_style( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: GetStyleArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let style = artifact.get_text_style(args.id).cloned().ok_or_else(|| { + SpreadsheetArtifactError::InvalidArgs { + action: request.action.clone(), + message: format!("text style `{}` was not found", args.id), + } + })?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Retrieved text style `{}`", args.id), + snapshot_for_artifact(artifact), + ); + response.style_id = Some(args.id); + response.serialized_dict = Some(to_serialized_value(style)?); + Ok(response) + } + + fn create_fill( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: CreateFillArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let style_id = artifact.create_fill( + args.fill, + args.source_fill_id, + args.merge_with_existing_components.unwrap_or(false), + )?; + let fill = artifact.get_fill(style_id).cloned().ok_or_else(|| { + SpreadsheetArtifactError::Serialization { + message: format!("created fill `{style_id}` was not available"), + } + })?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Created fill `{style_id}`"), + snapshot_for_artifact(artifact), + ); + response.style_id = Some(style_id); + response.serialized_dict = Some(to_serialized_value(fill)?); + Ok(response) + } + + fn get_fill( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: GetStyleArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let fill = artifact.get_fill(args.id).cloned().ok_or_else(|| { + SpreadsheetArtifactError::InvalidArgs { + action: request.action.clone(), + message: format!("fill `{}` was not found", args.id), + } + })?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Retrieved fill `{}`", args.id), + snapshot_for_artifact(artifact), + ); + response.style_id = Some(args.id); + response.serialized_dict = Some(to_serialized_value(fill)?); + Ok(response) + } + + fn create_border( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: CreateBorderArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let style_id = artifact.create_border( + args.border, + args.source_border_id, + args.merge_with_existing_components.unwrap_or(false), + )?; + let border = artifact.get_border(style_id).cloned().ok_or_else(|| { + SpreadsheetArtifactError::Serialization { + message: format!("created border `{style_id}` was not available"), + } + })?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Created border `{style_id}`"), + snapshot_for_artifact(artifact), + ); + response.style_id = Some(style_id); + response.serialized_dict = Some(to_serialized_value(border)?); + Ok(response) + } + + fn get_border( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: GetStyleArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let border = artifact.get_border(args.id).cloned().ok_or_else(|| { + SpreadsheetArtifactError::InvalidArgs { + action: request.action.clone(), + message: format!("border `{}` was not found", args.id), + } + })?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Retrieved border `{}`", args.id), + snapshot_for_artifact(artifact), + ); + response.style_id = Some(args.id); + response.serialized_dict = Some(to_serialized_value(border)?); + Ok(response) + } + + fn create_number_format( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: CreateNumberFormatArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let style_id = artifact.create_number_format( + args.number_format, + args.source_number_format_id, + args.merge_with_existing_components.unwrap_or(false), + )?; + let number_format = artifact + .get_number_format(style_id) + .cloned() + .ok_or_else(|| SpreadsheetArtifactError::Serialization { + message: format!("created number format `{style_id}` was not available"), + })?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Created number format `{style_id}`"), + snapshot_for_artifact(artifact), + ); + response.style_id = Some(style_id); + response.serialized_dict = Some(to_serialized_value(number_format)?); + Ok(response) + } + + fn get_number_format( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: GetStyleArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let number_format = artifact + .get_number_format(args.id) + .cloned() + .ok_or_else(|| SpreadsheetArtifactError::InvalidArgs { + action: request.action.clone(), + message: format!("number format `{}` was not found", args.id), + })?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Retrieved number format `{}`", args.id), + snapshot_for_artifact(artifact), + ); + response.style_id = Some(args.id); + response.serialized_dict = Some(to_serialized_value(number_format)?); + Ok(response) + } + + fn create_cell_format( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: CreateCellFormatArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let style_id = artifact.create_cell_format( + args.format, + args.source_format_id, + args.merge_with_existing_components.unwrap_or(false), + )?; + let format = artifact.get_cell_format(style_id).cloned().ok_or_else(|| { + SpreadsheetArtifactError::Serialization { + message: format!("created cell format `{style_id}` was not available"), + } + })?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Created cell format `{style_id}`"), + snapshot_for_artifact(artifact), + ); + response.style_id = Some(style_id); + response.serialized_dict = Some(to_serialized_value(format)?); + response.cell_format_summary = artifact.cell_format_summary(style_id); + Ok(response) + } + + fn get_cell_format( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: GetStyleArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let format = artifact.get_cell_format(args.id).cloned().ok_or_else(|| { + SpreadsheetArtifactError::InvalidArgs { + action: request.action.clone(), + message: format!("cell format `{}` was not found", args.id), + } + })?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Retrieved cell format `{}`", args.id), + snapshot_for_artifact(artifact), + ); + response.style_id = Some(args.id); + response.serialized_dict = Some(to_serialized_value(format)?); + response.cell_format_summary = artifact.cell_format_summary(args.id); + Ok(response) + } + + fn create_differential_format( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: CreateDifferentialFormatArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let style_id = artifact.create_differential_format(args.format); + let format = artifact + .get_differential_format(style_id) + .cloned() + .ok_or_else(|| SpreadsheetArtifactError::Serialization { + message: format!("created differential format `{style_id}` was not available"), + })?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Created differential format `{style_id}`"), + snapshot_for_artifact(artifact), + ); + response.style_id = Some(style_id); + response.serialized_dict = Some(to_serialized_value(format)?); + Ok(response) + } + + fn get_differential_format( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: GetStyleArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let format = artifact + .get_differential_format(args.id) + .cloned() + .ok_or_else(|| SpreadsheetArtifactError::InvalidArgs { + action: request.action.clone(), + message: format!("differential format `{}` was not found", args.id), + })?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Retrieved differential format `{}`", args.id), + snapshot_for_artifact(artifact), + ); + response.style_id = Some(args.id); + response.serialized_dict = Some(to_serialized_value(format)?); + Ok(response) + } + + fn get_cell_format_summary( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: CellAddressArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let sheet = artifact.sheet_lookup( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let cell = sheet.get_cell_view(CellAddress::parse(&args.address)?); + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Retrieved cell format summary for `{}`", args.address), + snapshot_for_artifact(artifact), + ); + response.cell_ref = Some(sheet.cell_ref(&args.address)?); + response.cell = Some(cell.clone()); + response.cell_format_summary = artifact.cell_format_summary(cell.style_index); + Ok(response) + } + + fn get_range_format_summary( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: RangeArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let sheet = artifact.sheet_lookup( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let range = CellRange::parse(&args.range)?; + let top_left_style_index = sheet.top_left_style_index(&range); + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Retrieved range format summary for `{}`", args.range), + snapshot_for_artifact(artifact), + ); + response.range_ref = Some(SpreadsheetCellRangeRef::new(sheet.name.clone(), &range)); + response.range_format = Some(sheet.range_format(&range)); + response.range = Some(sheet.get_range_view(&range)); + response.top_left_style_index = Some(top_left_style_index); + response.cell_format_summary = artifact.cell_format_summary(top_left_style_index); + Ok(response) + } + + fn get_reference( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: ReferenceArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let sheet = artifact.sheet_lookup( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!( + "Resolved reference `{}` on `{}`", + args.reference, sheet.name + ), + snapshot_for_artifact(artifact), + ); + match sheet.reference(&args.reference)? { + crate::SpreadsheetSheetReference::Cell { cell_ref } => { + let address = cell_ref.cell_address()?; + let cell = sheet.get_cell_view(address); + response.cell_format_summary = artifact.cell_format_summary(cell.style_index); + response.cell = Some(cell); + response.raw_cell = sheet.get_raw_cell(address); + response.cell_ref = Some(cell_ref); + } + crate::SpreadsheetSheetReference::Range { range_ref } => { + let range = range_ref.range()?; + let top_left_style_index = sheet.top_left_style_index(&range); + response.range = Some(sheet.get_range_view(&range)); + response.range_ref = Some(range_ref); + response.range_format = Some(sheet.range_format(&range)); + response.top_left_style_index = Some(top_left_style_index); + response.cell_format_summary = artifact.cell_format_summary(top_left_style_index); + response.rendered_text = Some(sheet.to_rendered_text(Some(&range))); + } + } + Ok(response) + } + + fn get_cell( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: CellAddressArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let sheet = artifact.sheet_lookup( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let cell = sheet.get_cell_view(CellAddress::parse(&args.address)?); + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Retrieved cell `{}` from `{}`", args.address, sheet.name), + snapshot_for_artifact(artifact), + ); + response.cell_format_summary = artifact.cell_format_summary(cell.style_index); + response.cell = Some(cell); + response.raw_cell = sheet.get_raw_cell(CellAddress::parse(&args.address)?); + response.cell_ref = Some(sheet.cell_ref(&args.address)?); + Ok(response) + } + + fn get_cell_by_indices( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: CellIndicesArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let sheet = artifact.sheet_lookup( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let address = CellAddress { + column: args.column_index, + row: args.row_index, + }; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!( + "Retrieved cell by indices ({}, {}) from `{}`", + args.column_index, args.row_index, sheet.name + ), + snapshot_for_artifact(artifact), + ); + let cell = sheet.get_cell_view_by_indices(args.column_index, args.row_index); + response.cell_format_summary = artifact.cell_format_summary(cell.style_index); + response.cell = Some(cell); + response.raw_cell = sheet.get_raw_cell(address); + response.cell_ref = Some(sheet.cell_ref(address.to_a1())?); + Ok(response) + } + + fn get_cell_field( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: CellFieldArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let sheet = artifact.sheet_lookup( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let value = sheet.get_cell_field(CellAddress::parse(&args.address)?, &args.field)?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!( + "Retrieved field `{}` from `{}` on `{}`", + args.field, args.address, sheet.name + ), + snapshot_for_artifact(artifact), + ); + response.cell_field = value; + response.cell_ref = Some(sheet.cell_ref(&args.address)?); + Ok(response) + } + + fn get_cell_field_by_indices( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: CellFieldByIndicesArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let sheet = artifact.sheet_lookup( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let value = + sheet.get_cell_field_by_indices(args.column_index, args.row_index, &args.field)?; + let address = CellAddress { + column: args.column_index, + row: args.row_index, + }; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!( + "Retrieved field `{}` from indices ({}, {}) on `{}`", + args.field, args.column_index, args.row_index, sheet.name + ), + snapshot_for_artifact(artifact), + ); + response.cell_field = value; + response.cell_ref = Some(sheet.cell_ref(address.to_a1())?); + Ok(response) + } + + fn get_range( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: RangeArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let sheet = artifact.sheet_lookup( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let range = CellRange::parse(&args.range)?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Retrieved range `{}` from `{}`", args.range, sheet.name), + snapshot_for_artifact(artifact), + ); + response.range = Some(sheet.get_range_view(&range)); + response.range_ref = Some(SpreadsheetCellRangeRef::new(sheet.name.clone(), &range)); + response.range_format = Some(sheet.range_format(&range)); + response.top_left_style_index = Some(sheet.top_left_style_index(&range)); + response.cell_format_summary = + artifact.cell_format_summary(sheet.top_left_style_index(&range)); + response.rendered_text = Some(sheet.to_rendered_text(Some(&range))); + Ok(response) + } + + fn set_cell_value( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let action = request.action.clone(); + let args: SetCellValueArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let address = CellAddress::parse(&args.address)?; + let recalculate = args.recalculate.unwrap_or(artifact.auto_recalculate); + { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let value = normalize_optional_cell_value(args.value)?; + sheet.set_value(address, value)?; + } + if recalculate { + artifact.recalculate(); + } + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + action.clone(), + format!("Updated cell `{}`", args.address), + snapshot_for_artifact(artifact), + ); + let sheet = artifact.sheet_lookup( + &action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + response.cell = Some(sheet.get_cell_view(address)); + response.cell_ref = Some(sheet.cell_ref(&args.address)?); + Ok(response) + } + + fn set_range_value( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let action = request.action.clone(); + let args: SetRangeValueArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let range = CellRange::parse(&args.range)?; + let recalculate = args.recalculate.unwrap_or(artifact.auto_recalculate); + { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + sheet.set_range_to_value(&range, normalize_optional_cell_value(args.value)?)?; + } + if recalculate { + artifact.recalculate(); + } + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + action.clone(), + format!("Updated range `{}` to a single value", args.range), + snapshot_for_artifact(artifact), + ); + let sheet = artifact.sheet_lookup( + &action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + response.range = Some(sheet.get_range_view(&range)); + response.range_ref = Some(SpreadsheetCellRangeRef::new(sheet.name.clone(), &range)); + Ok(response) + } + + fn set_range_values( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let action = request.action.clone(); + let args: SetRangeValuesArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let range = CellRange::parse(&args.range)?; + let recalculate = args.recalculate.unwrap_or(artifact.auto_recalculate); + let values = normalize_value_matrix(args.values, &request.action)?; + { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + sheet.set_values_matrix(&range, &values)?; + } + if recalculate { + artifact.recalculate(); + } + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + action.clone(), + format!("Updated range `{}`", args.range), + snapshot_for_artifact(artifact), + ); + let sheet = artifact.sheet_lookup( + &action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + response.range = Some(sheet.get_range_view(&range)); + response.range_ref = Some(SpreadsheetCellRangeRef::new(sheet.name.clone(), &range)); + Ok(response) + } + + fn set_cell_formula( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let action = request.action.clone(); + let args: SetCellFormulaArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let address = CellAddress::parse(&args.address)?; + let recalculate = args.recalculate.unwrap_or(artifact.auto_recalculate); + { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + sheet.set_formula(address, Some(normalize_formula(args.formula)))?; + } + if recalculate { + artifact.recalculate(); + } + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + action.clone(), + format!("Updated formula in `{}`", args.address), + snapshot_for_artifact(artifact), + ); + let sheet = artifact.sheet_lookup( + &action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + response.cell = Some(sheet.get_cell_view(address)); + response.cell_ref = Some(sheet.cell_ref(&args.address)?); + Ok(response) + } + + fn set_range_formula( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let action = request.action.clone(); + let args: SetRangeFormulaArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let range = CellRange::parse(&args.range)?; + let recalculate = args.recalculate.unwrap_or(artifact.auto_recalculate); + { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + sheet.set_range_to_formula(&range, Some(normalize_formula(args.formula)))?; + } + if recalculate { + artifact.recalculate(); + } + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + action.clone(), + format!("Updated range `{}` to a single formula", args.range), + snapshot_for_artifact(artifact), + ); + let sheet = artifact.sheet_lookup( + &action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + response.range = Some(sheet.get_range_view(&range)); + response.range_ref = Some(SpreadsheetCellRangeRef::new(sheet.name.clone(), &range)); + Ok(response) + } + + fn set_range_formulas( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let action = request.action.clone(); + let args: SetRangeFormulasArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let range = CellRange::parse(&args.range)?; + let recalculate = args.recalculate.unwrap_or(artifact.auto_recalculate); + let formulas = args + .formulas + .into_iter() + .map(|row| { + row.into_iter() + .map(|value| value.map(normalize_formula)) + .collect() + }) + .collect::>>>(); + { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + sheet.set_formulas_matrix(&range, &formulas)?; + } + if recalculate { + artifact.recalculate(); + } + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + action.clone(), + format!("Updated formulas in `{}`", args.range), + snapshot_for_artifact(artifact), + ); + let sheet = artifact.sheet_lookup( + &action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + response.range = Some(sheet.get_range_view(&range)); + response.range_ref = Some(SpreadsheetCellRangeRef::new(sheet.name.clone(), &range)); + Ok(response) + } + + fn set_cell_style( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: SetCellStyleArgs = parse_args(&request.action, &request.args)?; + let range = CellRange::parse(&args.address)?; + self.set_style_impl( + request, + args.sheet_name, + args.sheet_index, + range, + args.style_index, + ) + } + + fn set_range_style( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: SetRangeStyleArgs = parse_args(&request.action, &request.args)?; + let range = CellRange::parse(&args.range)?; + self.set_style_impl( + request, + args.sheet_name, + args.sheet_index, + range, + args.style_index, + ) + } + + fn set_style_impl( + &mut self, + request: SpreadsheetArtifactRequest, + sheet_name: Option, + sheet_index: Option, + range: CellRange, + style_index: u32, + ) -> Result { + let action = request.action.clone(); + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + { + let sheet = artifact.sheet_lookup_mut( + &request.action, + sheet_name.as_deref(), + sheet_index.map(|value| value as usize), + )?; + sheet.set_style_index(&range, style_index)?; + } + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + action.clone(), + format!("Updated style index {} on `{}`", style_index, range.to_a1()), + snapshot_for_artifact(artifact), + ); + let sheet = artifact.sheet_lookup( + &action, + sheet_name.as_deref(), + sheet_index.map(|value| value as usize), + )?; + response.range = Some(sheet.get_range_view(&range)); + response.range_ref = Some(SpreadsheetCellRangeRef::new(sheet.name.clone(), &range)); + Ok(response) + } + + fn clear_range( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let action = request.action.clone(); + let args: ClearRangeArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let range = CellRange::parse(&args.range)?; + let recalculate = args.recalculate.unwrap_or(artifact.auto_recalculate); + { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + sheet.clear_range(&range, args.fields.as_deref())?; + } + if recalculate { + artifact.recalculate(); + } + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + action.clone(), + format!("Cleared range `{}`", args.range), + snapshot_for_artifact(artifact), + ); + let sheet = artifact.sheet_lookup( + &action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + response.range = Some(sheet.get_range_view(&range)); + response.range_ref = Some(SpreadsheetCellRangeRef::new(sheet.name.clone(), &range)); + Ok(response) + } + + fn merge_range( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let action = request.action.clone(); + let args: MergeRangeArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let range = CellRange::parse(&args.range)?; + { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + sheet.merge_cells(&range, args.raise_on_conflict.unwrap_or(false))?; + } + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + action.clone(), + format!("Merged `{}`", args.range), + snapshot_for_artifact(artifact), + ); + let sheet = artifact.sheet_lookup( + &action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + response.range = Some(sheet.get_range_view(&range)); + response.range_ref = Some(SpreadsheetCellRangeRef::new(sheet.name.clone(), &range)); + Ok(response) + } + + fn cite_cell( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let action = request.action.clone(); + let args: CiteCellArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let address = CellAddress::parse(&args.address)?; + let citation = SpreadsheetCitation { + tether_id: args.tether_id, + start_line: args.start_line, + end_line: args.end_line, + content_reference_type: args.content_reference_type, + source_type: args.source_type, + }; + { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let cell_ref = sheet.cell_ref(&args.address)?; + cell_ref.cite(sheet, citation)?; + } + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + action.clone(), + format!("Attached citation to cell `{}`", args.address), + snapshot_for_artifact(artifact), + ); + let sheet = artifact.sheet_lookup( + &action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + response.cell = Some(sheet.get_cell_view(address)); + response.cell_ref = Some(sheet.cell_ref(&args.address)?); + Ok(response) + } + + fn unmerge_range( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let action = request.action.clone(); + let args: RangeArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let range = CellRange::parse(&args.range)?; + { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + sheet.unmerge_cells(&range); + } + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + action.clone(), + format!("Unmerged `{}`", args.range), + snapshot_for_artifact(artifact), + ); + let sheet = artifact.sheet_lookup( + &action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + response.range = Some(sheet.get_range_view(&range)); + Ok(response) + } + + fn cite_range( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let action = request.action.clone(); + let args: CiteRangeArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let range = CellRange::parse(&args.range)?; + let citation = SpreadsheetCitation { + tether_id: args.tether_id, + start_line: args.start_line, + end_line: args.end_line, + content_reference_type: args.content_reference_type, + source_type: args.source_type, + }; + { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + sheet.cite_range(&range, citation)?; + } + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + action.clone(), + format!("Attached citation to `{}`", args.range), + snapshot_for_artifact(artifact), + ); + let sheet = artifact.sheet_lookup( + &action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + response.range = Some(sheet.get_range_view(&range)); + Ok(response) + } + + fn calculate( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + artifact.recalculate(); + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + "Recalculated workbook".to_string(), + snapshot_for_artifact(artifact), + ); + response.workbook_summary = Some(artifact.summary()); + Ok(response) + } + + fn serialize_dict( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + "Serialized workbook to dict".to_string(), + snapshot_for_artifact(artifact), + ); + response.serialized_dict = Some(artifact.to_dict()?); + Ok(response) + } + + fn serialize_json( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + "Serialized workbook to JSON".to_string(), + snapshot_for_artifact(artifact), + ); + response.serialized_json = Some(artifact.to_json()?); + Ok(response) + } + + fn serialize_bytes( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + "Serialized workbook to bytes".to_string(), + snapshot_for_artifact(artifact), + ); + response.serialized_bytes_base64 = Some(artifact.to_bytes_base64()); + Ok(response) + } + + fn deserialize_dict( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: DeserializeDictArgs = parse_args(&request.action, &request.args)?; + let artifact = SpreadsheetArtifact::from_dict(args.data, args.artifact_id)?; + let artifact_id = artifact.artifact_id.clone(); + let snapshot = snapshot_for_artifact(&artifact); + self.documents.insert(artifact_id.clone(), artifact); + Ok(SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + "Deserialized workbook from dict".to_string(), + snapshot, + )) + } + + fn deserialize_json( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: DeserializeJsonArgs = parse_args(&request.action, &request.args)?; + let artifact = SpreadsheetArtifact::from_json(args.json, args.artifact_id)?; + let artifact_id = artifact.artifact_id.clone(); + let snapshot = snapshot_for_artifact(&artifact); + self.documents.insert(artifact_id.clone(), artifact); + Ok(SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + "Deserialized workbook from JSON".to_string(), + snapshot, + )) + } + + fn deserialize_bytes( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: DeserializeBytesArgs = parse_args(&request.action, &request.args)?; + let bytes = BASE64_STANDARD.decode(args.bytes_base64).map_err(|error| { + SpreadsheetArtifactError::Serialization { + message: error.to_string(), + } + })?; + let artifact = SpreadsheetArtifact::from_bytes(&bytes, args.artifact_id)?; + let artifact_id = artifact.artifact_id.clone(); + let snapshot = snapshot_for_artifact(&artifact); + self.documents.insert(artifact_id.clone(), artifact); + Ok(SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + "Deserialized workbook from bytes".to_string(), + snapshot, + )) + } + + fn delete_artifact( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let artifact_id = required_artifact_id(&request)?; + let artifact = self.documents.remove(&artifact_id).ok_or_else(|| { + SpreadsheetArtifactError::UnknownArtifactId { + action: request.action.clone(), + artifact_id: artifact_id.clone(), + } + })?; + let snapshot = snapshot_for_artifact(&artifact); + Ok(SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + "Deleted spreadsheet artifact".to_string(), + snapshot, + )) + } + + fn get_artifact( + &self, + artifact_id: &str, + action: &str, + ) -> Result<&SpreadsheetArtifact, SpreadsheetArtifactError> { + self.documents + .get(artifact_id) + .ok_or_else(|| SpreadsheetArtifactError::UnknownArtifactId { + action: action.to_string(), + artifact_id: artifact_id.to_string(), + }) + } + + fn get_artifact_mut( + &mut self, + artifact_id: &str, + action: &str, + ) -> Result<&mut SpreadsheetArtifact, SpreadsheetArtifactError> { + self.documents.get_mut(artifact_id).ok_or_else(|| { + SpreadsheetArtifactError::UnknownArtifactId { + action: action.to_string(), + artifact_id: artifact_id.to_string(), + } + }) + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct SpreadsheetArtifactResponse { + pub artifact_id: String, + pub action: String, + pub summary: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub exported_paths: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub artifact_snapshot: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub workbook_summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sheet_list: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub chart_list: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub table_list: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub conditional_format_list: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub pivot_table_list: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub sheet_ref: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cell_ref: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub range_ref: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub range_format: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cell: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub raw_cell: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub style_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cell_field: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub range: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub top_left_style_index: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cell_format_summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub rendered_text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub rendered_html: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub row_height: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub serialized_dict: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub serialized_json: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub serialized_bytes_base64: Option, +} + +impl SpreadsheetArtifactResponse { + fn new( + artifact_id: String, + action: String, + summary: String, + artifact_snapshot: SpreadsheetArtifactSnapshot, + ) -> Self { + Self { + artifact_id, + action, + summary, + exported_paths: Vec::new(), + artifact_snapshot: Some(artifact_snapshot), + workbook_summary: None, + sheet_list: None, + chart_list: None, + table_list: None, + conditional_format_list: None, + pivot_table_list: None, + sheet_ref: None, + cell_ref: None, + range_ref: None, + range_format: None, + cell: None, + raw_cell: None, + style_id: None, + cell_field: None, + range: None, + top_left_style_index: None, + cell_format_summary: None, + rendered_text: None, + rendered_html: None, + row_height: None, + serialized_dict: None, + serialized_json: None, + serialized_bytes_base64: None, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct SpreadsheetSheetRef { + pub sheet_name: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SpreadsheetArtifactSnapshot { + pub sheet_count: usize, + pub sheet_names: Vec, + pub sheets: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SpreadsheetSheetSnapshot { + pub sheet_id: String, + pub name: String, + pub filled_rows: usize, + pub filled_columns: usize, + pub minimum_range_filled: String, + pub merged_range_count: usize, + pub chart_count: usize, + pub table_count: usize, + pub conditional_format_count: usize, + pub pivot_table_count: usize, +} + +#[derive(Debug, Deserialize)] +struct CreateArgs { + name: Option, + auto_recalculate: Option, +} + +#[derive(Debug, Deserialize)] +struct PathArgs { + path: PathBuf, +} + +#[derive(Debug, Deserialize)] +struct SaveArgs { + path: PathBuf, + file_type: Option, +} + +#[derive(Debug, Deserialize)] +struct RenderArgs { + output_path: Option, + sheet_name: Option, + sheet_index: Option, + range: Option, + center_address: Option, + width: Option, + height: Option, + include_headers: Option, + scale: Option, + performance_mode: Option, +} + +#[derive(Debug, Deserialize)] +struct SheetLookupArgs { + sheet_name: Option, + sheet_index: Option, + range: Option, +} + +#[derive(Debug, Deserialize)] +struct ChartLookupArgs { + sheet_name: Option, + sheet_index: Option, + chart_id: Option, + chart_index: Option, +} + +#[derive(Debug, Deserialize)] +struct CreateChartArgs { + sheet_name: Option, + sheet_index: Option, + chart_type: crate::SpreadsheetChartType, + source_sheet_name: Option, + source_range: String, + options: Option, +} + +#[derive(Debug, Deserialize)] +struct AddChartSeriesArgs { + sheet_name: Option, + sheet_index: Option, + chart_id: Option, + chart_index: Option, + series: crate::SpreadsheetChartSeries, +} + +#[derive(Debug, Deserialize)] +struct SetChartPropertiesArgs { + #[serde(flatten)] + lookup: ChartLookupArgs, + properties: crate::SpreadsheetChartProperties, +} + +#[derive(Debug, Deserialize)] +struct TableLookupArgs { + sheet_name: Option, + sheet_index: Option, + name: Option, + display_name: Option, + id: Option, +} + +#[derive(Debug, Deserialize)] +struct CreateTableArgs { + sheet_name: Option, + sheet_index: Option, + range: String, + options: crate::SpreadsheetCreateTableOptions, +} + +#[derive(Debug, Deserialize)] +struct SetTableStyleArgs { + #[serde(flatten)] + lookup: TableLookupArgs, + options: crate::SpreadsheetTableStyleOptions, +} + +#[derive(Debug, Deserialize)] +struct RenameTableColumnArgs { + #[serde(flatten)] + lookup: TableLookupArgs, + column_id: Option, + column_name: Option, + new_name: String, +} + +#[derive(Debug, Deserialize)] +struct SetTableColumnTotalsArgs { + #[serde(flatten)] + lookup: TableLookupArgs, + column_id: Option, + column_name: Option, + totals_row_label: Option, + totals_row_function: Option, +} + +#[derive(Debug, Deserialize)] +struct AddConditionalFormatArgs { + sheet_name: Option, + sheet_index: Option, + format: crate::SpreadsheetConditionalFormat, +} + +#[derive(Debug, Deserialize)] +struct DeleteConditionalFormatArgs { + sheet_name: Option, + sheet_index: Option, + id: u32, +} + +#[derive(Debug, Deserialize)] +struct PivotTableLookupArgs { + sheet_name: Option, + sheet_index: Option, + name: Option, + index: Option, +} + +#[derive(Debug, Deserialize)] +struct CreateSheetArgs { + name: String, +} + +#[derive(Debug, Deserialize)] +struct RenameSheetArgs { + old_name: Option, + sheet_index: Option, + new_name: String, +} + +#[derive(Debug, Deserialize)] +struct DeleteSheetArgs { + sheet_name: Option, + sheet_index: Option, +} + +#[derive(Debug, Deserialize)] +struct SetSheetPropertiesArgs { + sheet_name: Option, + sheet_index: Option, + default_row_height: Option, + default_column_width: Option, + show_grid_lines: Option, +} + +#[derive(Debug, Deserialize)] +struct SetColumnWidthsArgs { + sheet_name: Option, + sheet_index: Option, + reference: String, + width: f64, +} + +#[derive(Debug, Deserialize)] +struct SetColumnWidthsBulkArgs { + sheet_name: Option, + sheet_index: Option, + widths: BTreeMap, +} + +#[derive(Debug, Deserialize)] +struct SetRowHeightArgs { + sheet_name: Option, + sheet_index: Option, + row_index: u32, + height: Option, +} + +#[derive(Debug, Deserialize)] +struct SetRowHeightsArgs { + sheet_name: Option, + sheet_index: Option, + start_row_index: u32, + end_row_index: u32, + height: Option, +} + +#[derive(Debug, Deserialize)] +struct SetRowHeightsBulkArgs { + sheet_name: Option, + sheet_index: Option, + heights: BTreeMap>, +} + +#[derive(Debug, Deserialize)] +struct GetRowHeightArgs { + sheet_name: Option, + sheet_index: Option, + row_index: u32, +} + +#[derive(Debug, Deserialize)] +struct CreateTextStyleArgs { + style: crate::SpreadsheetTextStyle, + source_style_id: Option, + merge_with_existing_components: Option, +} + +#[derive(Debug, Deserialize)] +struct CreateFillArgs { + fill: crate::SpreadsheetFill, + source_fill_id: Option, + merge_with_existing_components: Option, +} + +#[derive(Debug, Deserialize)] +struct CreateBorderArgs { + border: crate::SpreadsheetBorder, + source_border_id: Option, + merge_with_existing_components: Option, +} + +#[derive(Debug, Deserialize)] +struct CreateNumberFormatArgs { + number_format: crate::SpreadsheetNumberFormat, + source_number_format_id: Option, + merge_with_existing_components: Option, +} + +#[derive(Debug, Deserialize)] +struct CreateCellFormatArgs { + format: crate::SpreadsheetCellFormat, + source_format_id: Option, + merge_with_existing_components: Option, +} + +#[derive(Debug, Deserialize)] +struct CreateDifferentialFormatArgs { + format: crate::SpreadsheetDifferentialFormat, +} + +#[derive(Debug, Deserialize)] +struct GetStyleArgs { + id: u32, +} + +#[derive(Debug, Deserialize)] +struct CellAddressArgs { + sheet_name: Option, + sheet_index: Option, + address: String, +} + +#[derive(Debug, Deserialize)] +struct ReferenceArgs { + sheet_name: Option, + sheet_index: Option, + reference: String, +} + +#[derive(Debug, Deserialize)] +struct CellIndicesArgs { + sheet_name: Option, + sheet_index: Option, + column_index: u32, + row_index: u32, +} + +#[derive(Debug, Deserialize)] +struct CellFieldArgs { + sheet_name: Option, + sheet_index: Option, + address: String, + field: String, +} + +#[derive(Debug, Deserialize)] +struct CellFieldByIndicesArgs { + sheet_name: Option, + sheet_index: Option, + column_index: u32, + row_index: u32, + field: String, +} + +#[derive(Debug, Deserialize)] +struct RangeArgs { + sheet_name: Option, + sheet_index: Option, + range: String, +} + +#[derive(Debug, Deserialize)] +struct SetCellValueArgs { + sheet_name: Option, + sheet_index: Option, + address: String, + value: Value, + recalculate: Option, +} + +#[derive(Debug, Deserialize)] +struct SetRangeValuesArgs { + sheet_name: Option, + sheet_index: Option, + range: String, + values: Vec>, + recalculate: Option, +} + +#[derive(Debug, Deserialize)] +struct SetRangeValueArgs { + sheet_name: Option, + sheet_index: Option, + range: String, + value: Value, + recalculate: Option, +} + +#[derive(Debug, Deserialize)] +struct SetCellFormulaArgs { + sheet_name: Option, + sheet_index: Option, + address: String, + formula: String, + recalculate: Option, +} + +#[derive(Debug, Deserialize)] +struct SetRangeFormulasArgs { + sheet_name: Option, + sheet_index: Option, + range: String, + formulas: Vec>>, + recalculate: Option, +} + +#[derive(Debug, Deserialize)] +struct SetRangeFormulaArgs { + sheet_name: Option, + sheet_index: Option, + range: String, + formula: String, + recalculate: Option, +} + +#[derive(Debug, Deserialize)] +struct SetCellStyleArgs { + sheet_name: Option, + sheet_index: Option, + address: String, + style_index: u32, +} + +#[derive(Debug, Deserialize)] +struct SetRangeStyleArgs { + sheet_name: Option, + sheet_index: Option, + range: String, + style_index: u32, +} + +#[derive(Debug, Deserialize)] +struct ClearRangeArgs { + sheet_name: Option, + sheet_index: Option, + range: String, + fields: Option>, + recalculate: Option, +} + +#[derive(Debug, Deserialize)] +struct MergeRangeArgs { + sheet_name: Option, + sheet_index: Option, + range: String, + raise_on_conflict: Option, +} + +#[derive(Debug, Deserialize)] +struct CiteRangeArgs { + sheet_name: Option, + sheet_index: Option, + range: String, + tether_id: String, + start_line: Option, + end_line: Option, + content_reference_type: Option, + source_type: Option, +} + +#[derive(Debug, Deserialize)] +struct CiteCellArgs { + sheet_name: Option, + sheet_index: Option, + address: String, + tether_id: String, + start_line: Option, + end_line: Option, + content_reference_type: Option, + source_type: Option, +} + +#[derive(Debug, Deserialize)] +struct DeserializeDictArgs { + data: Value, + artifact_id: Option, +} + +#[derive(Debug, Deserialize)] +struct DeserializeJsonArgs { + json: String, + artifact_id: Option, +} + +#[derive(Debug, Deserialize)] +struct DeserializeBytesArgs { + bytes_base64: String, + artifact_id: Option, +} + +fn snapshot_for_artifact(artifact: &SpreadsheetArtifact) -> SpreadsheetArtifactSnapshot { + SpreadsheetArtifactSnapshot { + sheet_count: artifact.sheets.len(), + sheet_names: artifact.list_sheet_names(), + sheets: artifact + .sheets + .iter() + .map(|sheet| SpreadsheetSheetSnapshot { + sheet_id: sheet.sheet_id.clone(), + name: sheet.name.clone(), + filled_rows: sheet.filled_rows(), + filled_columns: sheet.filled_columns(), + minimum_range_filled: sheet.minimum_range_filled(), + merged_range_count: sheet.merged_ranges.len(), + chart_count: sheet.charts.len(), + table_count: sheet.tables.len(), + conditional_format_count: sheet.conditional_formats.len(), + pivot_table_count: sheet.pivot_tables.len(), + }) + .collect(), + } +} + +fn chart_lookup_from_args(args: &ChartLookupArgs) -> crate::SpreadsheetChartLookup { + crate::SpreadsheetChartLookup { + id: args.chart_id, + index: args.chart_index.map(|value| value as usize), + } +} + +fn table_lookup_from_args(args: &TableLookupArgs) -> crate::SpreadsheetTableLookup<'_> { + crate::SpreadsheetTableLookup { + name: args.name.as_deref(), + display_name: args.display_name.as_deref(), + id: args.id, + } +} + +fn pivot_lookup_from_args(args: &PivotTableLookupArgs) -> crate::SpreadsheetPivotTableLookup<'_> { + crate::SpreadsheetPivotTableLookup { + name: args.name.as_deref(), + index: args.index.map(|value| value as usize), + } +} + +fn sheet_reference(sheet: &crate::SpreadsheetSheet) -> SpreadsheetSheetRef { + SpreadsheetSheetRef { + sheet_name: sheet.name.clone(), + } +} + +fn normalize_optional_cell_value( + value: Value, +) -> Result, SpreadsheetArtifactError> { + if value.is_null() { + Ok(None) + } else { + Ok(Some(SpreadsheetCellValue::try_from(value)?)) + } +} + +fn normalize_value_matrix( + values: Vec>, + action: &str, +) -> Result>>, SpreadsheetArtifactError> { + if values.is_empty() { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: action.to_string(), + message: "values matrix cannot be empty".to_string(), + }); + } + let width = values.first().map(Vec::len).unwrap_or(0); + if width == 0 || values.iter().any(|row| row.len() != width) { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: action.to_string(), + message: "values matrix must be rectangular".to_string(), + }); + } + values + .into_iter() + .map(|row| { + row.into_iter() + .map(normalize_optional_cell_value) + .collect::, _>>() + }) + .collect::, _>>() +} + +fn normalize_formula(formula: String) -> String { + let trimmed = formula.trim(); + if trimmed.starts_with('=') { + trimmed.to_string() + } else { + format!("={trimmed}") + } +} + +fn render_options_from_args( + args: RenderArgs, +) -> Result { + let scale = args.scale.unwrap_or(1.0); + if scale <= 0.0 { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: "render".to_string(), + message: "render scale must be positive".to_string(), + }); + } + Ok(crate::SpreadsheetRenderOptions { + output_path: args.output_path, + center_address: args.center_address, + width: args.width, + height: args.height, + include_headers: args.include_headers.unwrap_or(true), + scale, + performance_mode: args.performance_mode.unwrap_or(false), + }) +} + +fn required_artifact_id( + request: &SpreadsheetArtifactRequest, +) -> Result { + request + .artifact_id + .clone() + .ok_or_else(|| SpreadsheetArtifactError::MissingArtifactId { + action: request.action.clone(), + }) +} + +fn parse_args Deserialize<'de>>( + action: &str, + value: &Value, +) -> Result { + serde_json::from_value(value.clone()).map_err(|error| SpreadsheetArtifactError::InvalidArgs { + action: action.to_string(), + message: error.to_string(), + }) +} + +fn resolve_path(cwd: &Path, path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + cwd.join(path) + } +} + +fn to_serialized_value(value: T) -> Result { + serde_json::to_value(value).map_err(|error| SpreadsheetArtifactError::Serialization { + message: error.to_string(), + }) +} diff --git a/codex-rs/artifact-spreadsheet/src/model.rs b/codex-rs/artifact-spreadsheet/src/model.rs new file mode 100644 index 0000000000..747eedbcea --- /dev/null +++ b/codex-rs/artifact-spreadsheet/src/model.rs @@ -0,0 +1,1787 @@ +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::path::Path; +use std::path::PathBuf; + +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value; +use uuid::Uuid; + +use crate::CellAddress; +use crate::CellRange; +use crate::SpreadsheetArtifactError; +use crate::formula::recalculate_workbook; +use crate::parse_column_reference; +use crate::xlsx::import_xlsx; +use crate::xlsx::write_xlsx; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SpreadsheetFileType { + Xlsx, + Json, + Binary, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", content = "value", rename_all = "snake_case")] +pub enum SpreadsheetCellValue { + Bool(bool), + Integer(i64), + Float(f64), + String(String), + DateTime(String), + Error(String), +} + +impl SpreadsheetCellValue { + pub fn to_json_value(&self) -> Value { + match self { + Self::Bool(value) => Value::Bool(*value), + Self::Integer(value) => Value::Number((*value).into()), + Self::Float(value) => serde_json::Number::from_f64(*value) + .map(Value::Number) + .unwrap_or_else(|| Value::String(value.to_string())), + Self::String(value) | Self::DateTime(value) | Self::Error(value) => { + Value::String(value.clone()) + } + } + } + + pub fn as_f64(&self) -> Option { + match self { + Self::Integer(value) => Some(*value as f64), + Self::Float(value) => Some(*value), + _ => None, + } + } +} + +impl TryFrom for SpreadsheetCellValue { + type Error = SpreadsheetArtifactError; + + fn try_from(value: Value) -> Result { + match value { + Value::Object(_) => serde_json::from_value(value).map_err(|error| { + SpreadsheetArtifactError::Serialization { + message: error.to_string(), + } + }), + Value::Bool(value) => Ok(Self::Bool(value)), + Value::Number(value) => { + if let Some(integer) = value.as_i64() { + Ok(Self::Integer(integer)) + } else if let Some(float) = value.as_f64() { + Ok(Self::Float(float)) + } else { + Err(SpreadsheetArtifactError::Serialization { + message: "unsupported JSON number".to_string(), + }) + } + } + Value::String(value) => Ok(Self::String(value)), + Value::Null => Err(SpreadsheetArtifactError::Serialization { + message: "null is represented as an empty cell, not a cell value".to_string(), + }), + other => Err(SpreadsheetArtifactError::Serialization { + message: format!("unsupported JSON cell value `{other}`"), + }), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpreadsheetCitation { + pub tether_id: String, + pub start_line: Option, + pub end_line: Option, + pub content_reference_type: Option, + pub source_type: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SpreadsheetCell { + pub value: Option, + pub formula: Option, + pub style_index: u32, + #[serde(default)] + pub citations: Vec, +} + +impl SpreadsheetCell { + pub fn to_dict(&self) -> Result { + serde_json::to_value(self).map_err(|error| SpreadsheetArtifactError::Serialization { + message: error.to_string(), + }) + } + + pub fn data(&self) -> Option { + if let Some(formula) = &self.formula { + return Some(Value::String(formula.clone())); + } + self.value.as_ref().map(SpreadsheetCellValue::to_json_value) + } + + pub fn is_empty(&self) -> bool { + self.value.is_none() + && self.formula.is_none() + && self.style_index == 0 + && self.citations.is_empty() + } + + pub fn is_calculation_error(&self) -> bool { + matches!( + self.value, + Some(SpreadsheetCellValue::Error(ref value)) + if matches!( + value.as_str(), + "#DIV/0!" + | "#N/A" + | "#NAME?" + | "#NUM!" + | "#REF!" + | "#VALUE!" + | "#CYCLE!" + | "#ERROR!" + | "#LIC!" + ) + ) + } + + pub fn calculation_error_message(&self) -> Option<&'static str> { + match self.value.as_ref() { + Some(SpreadsheetCellValue::Error(value)) => match value.as_str() { + "#DIV/0!" => Some("Division by zero"), + "#N/A" => Some("Value is not available"), + "#NAME?" => Some("Unknown function or name"), + "#NUM!" => Some("Invalid numeric result"), + "#REF!" => Some("Invalid cell reference"), + "#VALUE!" => Some("Invalid value type"), + "#CYCLE!" => Some("Formula cycle detected"), + "#ERROR!" => Some("Formula parse error"), + "#LIC!" => Some("Calculation engine license error"), + _ => None, + }, + _ => None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpreadsheetCellRef { + pub sheet_name: String, + pub address: String, +} + +impl SpreadsheetCellRef { + pub fn new(sheet_name: String, address: CellAddress) -> Self { + Self { + sheet_name, + address: address.to_a1(), + } + } + + pub fn cell_address(&self) -> Result { + CellAddress::parse(&self.address) + } + + pub fn get( + &self, + sheet: &SpreadsheetSheet, + ) -> Result { + self.ensure_sheet(sheet)?; + Ok(sheet.get_cell_view(self.cell_address()?)) + } + + pub fn data( + &self, + sheet: &SpreadsheetSheet, + ) -> Result, SpreadsheetArtifactError> { + Ok(self.get(sheet)?.data) + } + + pub fn raw_cell( + &self, + sheet: &SpreadsheetSheet, + ) -> Result, SpreadsheetArtifactError> { + self.ensure_sheet(sheet)?; + Ok(sheet.get_cell(self.cell_address()?).cloned()) + } + + pub fn to_dict(&self, sheet: &SpreadsheetSheet) -> Result { + Ok(match self.raw_cell(sheet)? { + Some(cell) => cell.to_dict()?, + None => Value::Object(Default::default()), + }) + } + + pub fn set_value( + &self, + sheet: &mut SpreadsheetSheet, + value: Option, + ) -> Result<(), SpreadsheetArtifactError> { + self.ensure_sheet(sheet)?; + sheet.set_value(self.cell_address()?, value) + } + + pub fn set_formula( + &self, + sheet: &mut SpreadsheetSheet, + formula: Option, + ) -> Result<(), SpreadsheetArtifactError> { + self.ensure_sheet(sheet)?; + sheet.set_formula(self.cell_address()?, formula) + } + + pub fn cite( + &self, + sheet: &mut SpreadsheetSheet, + citation: SpreadsheetCitation, + ) -> Result<(), SpreadsheetArtifactError> { + self.ensure_sheet(sheet)?; + sheet.cite_range( + &CellRange::from_start_end(self.cell_address()?, self.cell_address()?), + citation, + ) + } + + pub fn set_style_index( + &self, + sheet: &mut SpreadsheetSheet, + style_index: u32, + ) -> Result<(), SpreadsheetArtifactError> { + self.ensure_sheet(sheet)?; + sheet.set_style_index( + &CellRange::from_start_end(self.cell_address()?, self.cell_address()?), + style_index, + ) + } + + pub fn is_calculation_error( + &self, + sheet: &SpreadsheetSheet, + ) -> Result { + Ok(self.get(sheet)?.is_calculation_error) + } + + pub fn get_calculation_error_message( + &self, + sheet: &SpreadsheetSheet, + ) -> Result, SpreadsheetArtifactError> { + Ok(self.get(sheet)?.calculation_error_message) + } + + fn ensure_sheet(&self, sheet: &SpreadsheetSheet) -> Result<(), SpreadsheetArtifactError> { + if self.sheet_name != sheet.name { + return Err(SpreadsheetArtifactError::SheetLookup { + action: "cell_ref".to_string(), + message: format!( + "cell ref points to `{}` but sheet is `{}`", + self.sheet_name, sheet.name + ), + }); + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpreadsheetCellRangeRef { + pub sheet_name: String, + pub address: String, +} + +impl SpreadsheetCellRangeRef { + pub fn new(sheet_name: String, range: &CellRange) -> Self { + Self { + sheet_name, + address: range.to_a1(), + } + } + + pub fn range(&self) -> Result { + CellRange::parse(&self.address) + } + + pub fn get( + &self, + sheet: &SpreadsheetSheet, + ) -> Result { + self.ensure_sheet(sheet)?; + Ok(sheet.get_range_view(&self.range()?)) + } + + pub fn get_values( + &self, + sheet: &SpreadsheetSheet, + ) -> Result>>, SpreadsheetArtifactError> { + Ok(self.get(sheet)?.values) + } + + pub fn get_formulas( + &self, + sheet: &SpreadsheetSheet, + ) -> Result>>, SpreadsheetArtifactError> { + Ok(self.get(sheet)?.formulas) + } + + pub fn get_style_indices( + &self, + sheet: &SpreadsheetSheet, + ) -> Result>, SpreadsheetArtifactError> { + Ok(self.get(sheet)?.style_indices) + } + + pub fn get_data( + &self, + sheet: &SpreadsheetSheet, + ) -> Result>>, SpreadsheetArtifactError> { + Ok(self.get(sheet)?.data) + } + + pub fn top_left_style_index( + &self, + sheet: &SpreadsheetSheet, + ) -> Result { + self.ensure_sheet(sheet)?; + Ok(sheet.top_left_style_index(&self.range()?)) + } + + pub fn set_value( + &self, + sheet: &mut SpreadsheetSheet, + value: Option, + ) -> Result<(), SpreadsheetArtifactError> { + self.ensure_sheet(sheet)?; + sheet.set_range_to_value(&self.range()?, value) + } + + pub fn set_values( + &self, + sheet: &mut SpreadsheetSheet, + values: &[Vec>], + ) -> Result<(), SpreadsheetArtifactError> { + self.ensure_sheet(sheet)?; + sheet.set_values_matrix(&self.range()?, values) + } + + pub fn set_formula( + &self, + sheet: &mut SpreadsheetSheet, + formula: Option, + ) -> Result<(), SpreadsheetArtifactError> { + self.ensure_sheet(sheet)?; + sheet.set_range_to_formula(&self.range()?, formula) + } + + pub fn set_formulas( + &self, + sheet: &mut SpreadsheetSheet, + formulas: &[Vec>], + ) -> Result<(), SpreadsheetArtifactError> { + self.ensure_sheet(sheet)?; + sheet.set_formulas_matrix(&self.range()?, formulas) + } + + pub fn set_style_index( + &self, + sheet: &mut SpreadsheetSheet, + style_index: u32, + ) -> Result<(), SpreadsheetArtifactError> { + self.ensure_sheet(sheet)?; + sheet.set_style_index(&self.range()?, style_index) + } + + pub fn merge( + &self, + sheet: &mut SpreadsheetSheet, + raise_on_conflict: bool, + ) -> Result<(), SpreadsheetArtifactError> { + self.ensure_sheet(sheet)?; + sheet.merge_cells(&self.range()?, raise_on_conflict) + } + + pub fn unmerge(&self, sheet: &mut SpreadsheetSheet) -> Result<(), SpreadsheetArtifactError> { + self.ensure_sheet(sheet)?; + sheet.unmerge_cells(&self.range()?); + Ok(()) + } + + pub fn cite( + &self, + sheet: &mut SpreadsheetSheet, + citation: SpreadsheetCitation, + ) -> Result<(), SpreadsheetArtifactError> { + self.ensure_sheet(sheet)?; + sheet.cite_range(&self.range()?, citation) + } + + fn ensure_sheet(&self, sheet: &SpreadsheetSheet) -> Result<(), SpreadsheetArtifactError> { + if self.sheet_name != sheet.name { + return Err(SpreadsheetArtifactError::SheetLookup { + action: "range_ref".to_string(), + message: format!( + "range ref points to `{}` but sheet is `{}`", + self.sheet_name, sheet.name + ), + }); + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum SpreadsheetSheetReference { + Cell { cell_ref: SpreadsheetCellRef }, + Range { range_ref: SpreadsheetCellRangeRef }, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SpreadsheetSheetSummary { + pub name: String, + pub filled_rows: usize, + pub filled_columns: usize, + pub minimum_range_filled: String, + pub min_row_idx: Option, + pub max_row_idx: Option, + pub min_column_idx: Option, + pub max_column_idx: Option, + pub min_column_letter: Option, + pub max_column_letter: Option, + pub first_row_address_range: Option, + pub default_row_height: Option, + pub default_column_width: Option, + pub show_grid_lines: bool, + pub merged_range_count: usize, + pub chart_count: usize, + pub table_count: usize, + pub conditional_format_count: usize, + pub pivot_table_count: usize, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SpreadsheetSummary { + pub artifact_id: String, + pub sheets: Vec, + pub size_bytes: usize, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SpreadsheetCellView { + pub sheet_name: String, + pub address: String, + pub effective_address: String, + pub exists: bool, + pub value: Option, + pub formula: Option, + pub style_index: u32, + pub data: Option, + pub is_calculation_error: bool, + pub calculation_error_message: Option, + pub citations: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SpreadsheetRangeView { + pub sheet_name: String, + pub address: String, + pub values: Vec>>, + pub formulas: Vec>>, + pub style_indices: Vec>, + pub data: Vec>>, + pub is_single_cell: bool, + pub is_single_row: bool, + pub is_single_column: bool, + pub contains_merged_cells: bool, + pub is_exactly_one_merged_cell: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SpreadsheetSheet { + pub sheet_id: String, + pub name: String, + #[serde(default, with = "cell_map_serde")] + pub cells: BTreeMap, + #[serde(default)] + pub merged_ranges: Vec, + #[serde(default)] + pub charts: Vec, + #[serde(default)] + pub tables: Vec, + #[serde(default)] + pub conditional_formats: Vec, + #[serde(default)] + pub pivot_tables: Vec, + pub default_row_height: Option, + pub default_column_width: Option, + pub show_grid_lines: bool, + #[serde(default)] + pub column_widths: BTreeMap, + #[serde(default)] + pub row_heights: BTreeMap, +} + +mod cell_map_serde { + use std::collections::BTreeMap; + + use serde::Deserialize; + use serde::Deserializer; + use serde::Serialize; + use serde::Serializer; + + use crate::CellAddress; + use crate::SpreadsheetCell; + #[derive(Serialize, Deserialize)] + struct CellEntry { + address: String, + cell: SpreadsheetCell, + } + + pub fn serialize( + cells: &BTreeMap, + serializer: S, + ) -> Result + where + S: Serializer, + { + let entries = cells + .iter() + .map(|(address, cell)| CellEntry { + address: address.to_a1(), + cell: cell.clone(), + }) + .collect::>(); + entries.serialize(serializer) + } + + pub fn deserialize<'de, D>( + deserializer: D, + ) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let entries = Vec::::deserialize(deserializer)?; + let mut cells = BTreeMap::new(); + for entry in entries { + let address = CellAddress::parse(&entry.address).map_err(serde::de::Error::custom)?; + cells.insert(address, entry.cell); + } + Ok(cells) + } +} + +impl SpreadsheetSheet { + pub fn new(name: String) -> Self { + Self { + sheet_id: format!("sheet_{}", Uuid::new_v4().simple()), + name, + cells: BTreeMap::new(), + merged_ranges: Vec::new(), + charts: Vec::new(), + tables: Vec::new(), + conditional_formats: Vec::new(), + pivot_tables: Vec::new(), + default_row_height: None, + default_column_width: None, + show_grid_lines: true, + column_widths: BTreeMap::new(), + row_heights: BTreeMap::new(), + } + } + + pub fn filled_rows(&self) -> usize { + self.cells + .keys() + .map(|address| address.row) + .collect::>() + .len() + } + + pub fn filled_columns(&self) -> usize { + self.cells + .keys() + .map(|address| address.column) + .collect::>() + .len() + } + + pub fn is_empty(&self) -> bool { + self.cells.is_empty() + } + + pub fn minimum_range(&self) -> Option { + let mut iter = self.cells.keys(); + let first = *iter.next()?; + let mut min_row = first.row; + let mut max_row = first.row; + let mut min_column = first.column; + let mut max_column = first.column; + + for address in iter { + min_row = min_row.min(address.row); + max_row = max_row.max(address.row); + min_column = min_column.min(address.column); + max_column = max_column.max(address.column); + } + + Some(CellRange::from_start_end( + CellAddress { + column: min_column, + row: min_row, + }, + CellAddress { + column: max_column, + row: max_row, + }, + )) + } + + pub fn minimum_range_ref(&self) -> Option { + self.minimum_range() + .map(|range| SpreadsheetCellRangeRef::new(self.name.clone(), &range)) + } + + pub fn minimum_range_filled(&self) -> String { + self.minimum_range() + .map(|range| range.to_a1()) + .unwrap_or_default() + } + + pub fn min_column_letter(&self) -> Option { + self.minimum_range() + .map(|range| crate::column_index_to_letters(range.start.column)) + } + + pub fn max_column_letter(&self) -> Option { + self.minimum_range() + .map(|range| crate::column_index_to_letters(range.end.column)) + } + + pub fn first_row_address_range(&self) -> Option { + self.minimum_range().map(|range| { + CellRange::from_start_end( + CellAddress { + column: range.start.column, + row: range.start.row, + }, + CellAddress { + column: range.end.column, + row: range.start.row, + }, + ) + .to_a1() + }) + } + + pub fn summary(&self) -> SpreadsheetSheetSummary { + let minimum = self.minimum_range(); + SpreadsheetSheetSummary { + name: self.name.clone(), + filled_rows: self.filled_rows(), + filled_columns: self.filled_columns(), + minimum_range_filled: self.minimum_range_filled(), + min_row_idx: minimum.as_ref().map(|range| range.start.row), + max_row_idx: minimum.as_ref().map(|range| range.end.row), + min_column_idx: minimum.as_ref().map(|range| range.start.column), + max_column_idx: minimum.as_ref().map(|range| range.end.column), + min_column_letter: self.min_column_letter(), + max_column_letter: self.max_column_letter(), + first_row_address_range: self.first_row_address_range(), + default_row_height: self.default_row_height, + default_column_width: self.default_column_width, + show_grid_lines: self.show_grid_lines, + merged_range_count: self.merged_ranges.len(), + chart_count: self.charts.len(), + table_count: self.tables.len(), + conditional_format_count: self.conditional_formats.len(), + pivot_table_count: self.pivot_tables.len(), + } + } + + pub fn to_dict(&self) -> Result { + serde_json::to_value(self).map_err(|error| SpreadsheetArtifactError::Serialization { + message: error.to_string(), + }) + } + + pub fn cell_ref( + &self, + address: impl AsRef, + ) -> Result { + Ok(SpreadsheetCellRef::new( + self.name.clone(), + CellAddress::parse(address.as_ref())?, + )) + } + + pub fn range_ref( + &self, + address: impl AsRef, + ) -> Result { + let range = CellRange::parse(address.as_ref())?; + Ok(SpreadsheetCellRangeRef::new(self.name.clone(), &range)) + } + + pub fn reference( + &self, + address: impl AsRef, + ) -> Result { + let address = address.as_ref(); + if let Ok(cell_ref) = self.cell_ref(address) { + return Ok(SpreadsheetSheetReference::Cell { cell_ref }); + } + Ok(SpreadsheetSheetReference::Range { + range_ref: self.range_ref(address)?, + }) + } + + pub fn set_column_widths( + &mut self, + reference: &str, + width: f64, + ) -> Result<(), SpreadsheetArtifactError> { + if width <= 0.0 { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: "set_column_widths".to_string(), + message: "column width must be positive".to_string(), + }); + } + let (start, end) = parse_column_reference(reference)?; + for column in start..=end { + self.column_widths.insert(column, width); + } + Ok(()) + } + + pub fn set_column_widths_bulk( + &mut self, + widths: &BTreeMap, + ) -> Result<(), SpreadsheetArtifactError> { + for (reference, width) in widths { + self.set_column_widths(reference, *width)?; + } + Ok(()) + } + + pub fn get_column_width( + &self, + reference: &str, + ) -> Result, SpreadsheetArtifactError> { + let (start, end) = parse_column_reference(reference)?; + if start != end { + return Ok((start..=end) + .find_map(|column| self.column_widths.get(&column).copied()) + .or(self.default_column_width)); + } + Ok(self + .column_widths + .get(&start) + .copied() + .or(self.default_column_width)) + } + + pub fn set_row_height( + &mut self, + row_index: u32, + height: Option, + ) -> Result<(), SpreadsheetArtifactError> { + if row_index == 0 { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: "set_row_height".to_string(), + message: "row index must be positive".to_string(), + }); + } + if let Some(height) = height { + if height <= 0.0 { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: "set_row_height".to_string(), + message: "row height must be positive".to_string(), + }); + } + self.row_heights.insert(row_index, height); + } else { + self.row_heights.remove(&row_index); + } + Ok(()) + } + + pub fn set_row_heights( + &mut self, + start_row_index: u32, + end_row_index: u32, + height: Option, + ) -> Result<(), SpreadsheetArtifactError> { + if start_row_index == 0 || end_row_index == 0 { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: "set_row_heights".to_string(), + message: "row indices must be positive".to_string(), + }); + } + let start = start_row_index.min(end_row_index); + let end = start_row_index.max(end_row_index); + for row_index in start..=end { + self.set_row_height(row_index, height)?; + } + Ok(()) + } + + pub fn set_row_heights_bulk( + &mut self, + heights: &BTreeMap>, + ) -> Result<(), SpreadsheetArtifactError> { + for (row_index, height) in heights { + self.set_row_height(*row_index, *height)?; + } + Ok(()) + } + + pub fn get_row_height(&self, row_index: u32) -> Option { + self.row_heights + .get(&row_index) + .copied() + .or(self.default_row_height) + } + + pub fn cell_exists(&self, address: CellAddress) -> bool { + self.cells.contains_key(&address) + } + + pub fn merged_range_for(&self, address: CellAddress) -> Option<&CellRange> { + self.merged_ranges + .iter() + .find(|range| range.contains(address)) + } + + pub fn effective_address(&self, address: CellAddress) -> CellAddress { + self.merged_range_for(address) + .map(|range| range.start) + .unwrap_or(address) + } + + pub fn get_cell(&self, address: CellAddress) -> Option<&SpreadsheetCell> { + let effective = self.effective_address(address); + self.cells.get(&effective) + } + + pub fn get_cell_by_indices(&self, column: u32, row: u32) -> Option<&SpreadsheetCell> { + self.get_cell(CellAddress { column, row }) + } + + pub fn get_cell_mut(&mut self, address: CellAddress) -> Option<&mut SpreadsheetCell> { + let effective = self.effective_address(address); + self.cells.get_mut(&effective) + } + + pub fn get_or_create_cell_mut(&mut self, address: CellAddress) -> &mut SpreadsheetCell { + let effective = self.effective_address(address); + self.cells.entry(effective).or_insert(SpreadsheetCell { + value: None, + formula: None, + style_index: 0, + citations: Vec::new(), + }) + } + + pub fn clear_range( + &mut self, + range: &CellRange, + fields: Option<&[String]>, + ) -> Result<(), SpreadsheetArtifactError> { + self.ensure_range_write_allowed(range, false, "clear_range")?; + for address in range.addresses() { + if let Some(cell) = self.get_cell_mut(address) { + match fields { + Some(fields) => { + for field in fields { + match field.as_str() { + "value" => cell.value = None, + "formula" => cell.formula = None, + "style_index" => cell.style_index = 0, + "citations" => cell.citations.clear(), + other => { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: "clear_range".to_string(), + message: format!("unsupported cell field `{other}`"), + }); + } + } + } + } + None => { + cell.value = None; + cell.formula = None; + cell.style_index = 0; + cell.citations.clear(); + } + } + } + } + self.cells.retain(|_, cell| !cell.is_empty()); + Ok(()) + } + + pub fn set_value( + &mut self, + address: CellAddress, + value: Option, + ) -> Result<(), SpreadsheetArtifactError> { + let range = CellRange::from_start_end(address, address); + self.ensure_range_write_allowed(&range, false, "set_value")?; + let cell = self.get_or_create_cell_mut(address); + cell.formula = None; + cell.value = value; + if cell.is_empty() { + self.cells.remove(&address); + } + Ok(()) + } + + pub fn set_range_to_value( + &mut self, + range: &CellRange, + value: Option, + ) -> Result<(), SpreadsheetArtifactError> { + self.ensure_range_write_allowed(range, false, "set_range_to_value")?; + for address in range.addresses() { + let cell = self.get_or_create_cell_mut(address); + cell.formula = None; + cell.value = value.clone(); + } + self.cells.retain(|_, cell| !cell.is_empty()); + Ok(()) + } + + pub fn set_formula( + &mut self, + address: CellAddress, + formula: Option, + ) -> Result<(), SpreadsheetArtifactError> { + let range = CellRange::from_start_end(address, address); + self.ensure_range_write_allowed(&range, false, "set_formula")?; + let cell = self.get_or_create_cell_mut(address); + cell.formula = formula; + if cell.formula.is_none() { + cell.value = None; + } + if cell.is_empty() { + self.cells.remove(&address); + } + Ok(()) + } + + pub fn set_range_to_formula( + &mut self, + range: &CellRange, + formula: Option, + ) -> Result<(), SpreadsheetArtifactError> { + self.ensure_range_write_allowed(range, false, "set_range_to_formula")?; + for address in range.addresses() { + let cell = self.get_or_create_cell_mut(address); + cell.formula = formula.clone(); + } + self.cells.retain(|_, cell| !cell.is_empty()); + Ok(()) + } + + pub fn set_cell_values_to( + &mut self, + address: &str, + value: Option, + ) -> Result<(), SpreadsheetArtifactError> { + let range = CellRange::parse(address)?; + self.set_range_to_value(&range, value) + } + + pub fn set_cell_formulas_to( + &mut self, + address: &str, + formula: Option, + ) -> Result<(), SpreadsheetArtifactError> { + let range = CellRange::parse(address)?; + self.set_range_to_formula(&range, formula) + } + + pub fn set_style_index( + &mut self, + range: &CellRange, + style_index: u32, + ) -> Result<(), SpreadsheetArtifactError> { + self.ensure_range_write_allowed(range, false, "set_style_index")?; + for address in range.addresses() { + let cell = self.get_or_create_cell_mut(address); + cell.style_index = style_index; + } + self.cells.retain(|_, cell| !cell.is_empty()); + Ok(()) + } + + pub fn cite_range( + &mut self, + range: &CellRange, + citation: SpreadsheetCitation, + ) -> Result<(), SpreadsheetArtifactError> { + self.ensure_range_write_allowed(range, true, "cite_range")?; + for address in range.addresses() { + let cell = self.get_or_create_cell_mut(address); + cell.citations.push(citation.clone()); + } + Ok(()) + } + + pub fn set_values_matrix( + &mut self, + range: &CellRange, + values: &[Vec>], + ) -> Result<(), SpreadsheetArtifactError> { + self.ensure_range_write_allowed(range, false, "set_values_matrix")?; + if values.len() != range.height() || values.iter().any(|row| row.len() != range.width()) { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: "set_range_values".to_string(), + message: format!( + "matrix dimensions {}x{} do not match range {}x{}", + values.len(), + values.first().map(Vec::len).unwrap_or(0), + range.height(), + range.width() + ), + }); + } + + for (row_offset, row) in values.iter().enumerate() { + for (column_offset, value) in row.iter().enumerate() { + let address = CellAddress { + column: range.start.column + column_offset as u32, + row: range.start.row + row_offset as u32, + }; + let cell = self.get_or_create_cell_mut(address); + cell.formula = None; + cell.value = value.clone(); + } + } + self.cells.retain(|_, cell| !cell.is_empty()); + Ok(()) + } + + pub fn set_formulas_matrix( + &mut self, + range: &CellRange, + formulas: &[Vec>], + ) -> Result<(), SpreadsheetArtifactError> { + self.ensure_range_write_allowed(range, false, "set_formulas_matrix")?; + if formulas.len() != range.height() || formulas.iter().any(|row| row.len() != range.width()) + { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: "set_range_formulas".to_string(), + message: format!( + "matrix dimensions {}x{} do not match range {}x{}", + formulas.len(), + formulas.first().map(Vec::len).unwrap_or(0), + range.height(), + range.width() + ), + }); + } + + for (row_offset, row) in formulas.iter().enumerate() { + for (column_offset, formula) in row.iter().enumerate() { + let address = CellAddress { + column: range.start.column + column_offset as u32, + row: range.start.row + row_offset as u32, + }; + let cell = self.get_or_create_cell_mut(address); + cell.formula = formula.clone(); + } + } + self.cells.retain(|_, cell| !cell.is_empty()); + Ok(()) + } + + pub fn merge_cells( + &mut self, + range: &CellRange, + raise_on_conflict: bool, + ) -> Result<(), SpreadsheetArtifactError> { + for existing in &self.merged_ranges { + if existing.intersects(range) && existing != range { + if raise_on_conflict { + return Err(SpreadsheetArtifactError::MergeConflict { + action: "merge_range".to_string(), + range: range.to_a1(), + conflict: existing.to_a1(), + }); + } + return Ok(()); + } + } + self.merged_ranges.push(range.clone()); + self.merged_ranges + .sort_by_key(|entry| (entry.start.row, entry.start.column)); + Ok(()) + } + + pub fn unmerge_cells(&mut self, range: &CellRange) { + self.merged_ranges.retain(|entry| entry != range); + } + + pub fn contains_merged_cells(&self, range: &CellRange) -> bool { + self.merged_ranges + .iter() + .any(|entry| entry.intersects(range)) + } + + pub fn is_exactly_one_merged_cell(&self, range: &CellRange) -> bool { + self.merged_ranges.iter().any(|entry| entry == range) + } + + pub fn get_cell_view(&self, address: CellAddress) -> SpreadsheetCellView { + let effective = self.effective_address(address); + let cell = self.get_cell(address); + SpreadsheetCellView { + sheet_name: self.name.clone(), + address: address.to_a1(), + effective_address: effective.to_a1(), + exists: cell.is_some(), + value: cell.and_then(|entry| entry.value.clone()), + formula: cell.and_then(|entry| entry.formula.clone()), + style_index: cell.map(|entry| entry.style_index).unwrap_or(0), + data: cell.and_then(SpreadsheetCell::data), + is_calculation_error: cell + .map(SpreadsheetCell::is_calculation_error) + .unwrap_or(false), + calculation_error_message: cell + .and_then(SpreadsheetCell::calculation_error_message) + .map(str::to_string), + citations: cell + .map(|entry| entry.citations.clone()) + .unwrap_or_default(), + } + } + + pub fn get_cell_view_by_indices(&self, column: u32, row: u32) -> SpreadsheetCellView { + self.get_cell_view(CellAddress { column, row }) + } + + pub fn get_cell_field( + &self, + address: CellAddress, + field: &str, + ) -> Result, SpreadsheetArtifactError> { + let cell = self.get_cell(address); + Ok(match field { + "value" => cell.and_then(|entry| { + entry + .value + .as_ref() + .map(SpreadsheetCellValue::to_json_value) + }), + "formula" => cell.and_then(|entry| entry.formula.clone().map(Value::String)), + "style_index" => cell.map(|entry| Value::Number(entry.style_index.into())), + "data" => cell.and_then(SpreadsheetCell::data), + "citations" => cell + .map(|entry| serde_json::to_value(&entry.citations)) + .transpose() + .map_err(|error| SpreadsheetArtifactError::Serialization { + message: error.to_string(), + })?, + other => { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: "get_cell_field".to_string(), + message: format!("unsupported field `{other}`"), + }); + } + }) + } + + pub fn get_cell_field_by_indices( + &self, + column: u32, + row: u32, + field: &str, + ) -> Result, SpreadsheetArtifactError> { + self.get_cell_field(CellAddress { column, row }, field) + } + + pub fn get_raw_cell(&self, address: CellAddress) -> Option { + self.get_cell(address).cloned() + } + + pub fn top_left_style_index(&self, range: &CellRange) -> u32 { + self.get_cell(range.start) + .map(|cell| cell.style_index) + .unwrap_or(0) + } + + pub fn get_range_view(&self, range: &CellRange) -> SpreadsheetRangeView { + let mut values = Vec::new(); + let mut formulas = Vec::new(); + let mut style_indices = Vec::new(); + let mut data = Vec::new(); + + for row in range.start.row..=range.end.row { + let mut value_row = Vec::new(); + let mut formula_row = Vec::new(); + let mut style_row = Vec::new(); + let mut data_row = Vec::new(); + for column in range.start.column..=range.end.column { + let cell = self.get_cell(CellAddress { column, row }); + value_row.push(cell.and_then(|entry| entry.value.clone())); + formula_row.push(cell.and_then(|entry| entry.formula.clone())); + style_row.push(cell.map(|entry| entry.style_index).unwrap_or(0)); + data_row.push(cell.and_then(SpreadsheetCell::data)); + } + values.push(value_row); + formulas.push(formula_row); + style_indices.push(style_row); + data.push(data_row); + } + + SpreadsheetRangeView { + sheet_name: self.name.clone(), + address: range.to_a1(), + values, + formulas, + style_indices, + data, + is_single_cell: range.is_single_cell(), + is_single_row: range.is_single_row(), + is_single_column: range.is_single_column(), + contains_merged_cells: self.contains_merged_cells(range), + is_exactly_one_merged_cell: self.is_exactly_one_merged_cell(range), + } + } + + pub fn to_rendered_text(&self, range: Option<&CellRange>) -> String { + let target = range + .cloned() + .or_else(|| self.minimum_range()) + .unwrap_or_else(|| { + CellRange::from_start_end( + CellAddress { column: 1, row: 1 }, + CellAddress { column: 1, row: 1 }, + ) + }); + let mut lines = Vec::new(); + for row in target.start.row..=target.end.row { + let mut entries = Vec::new(); + for column in target.start.column..=target.end.column { + let view = self.get_cell_view(CellAddress { column, row }); + let text = match view.data { + Some(Value::String(value)) => value, + Some(Value::Bool(value)) => value.to_string(), + Some(Value::Number(value)) => value.to_string(), + Some(Value::Null) | None => String::new(), + Some(other) => other.to_string(), + }; + entries.push(text); + } + lines.push(entries.join("\t")); + } + lines.join("\n") + } + + pub fn cleanup_and_validate_sheet(&mut self) -> Result<(), SpreadsheetArtifactError> { + self.cells.retain(|_, cell| !cell.is_empty()); + + for (column, width) in &self.column_widths { + if *column == 0 || *width <= 0.0 { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: "cleanup_and_validate_sheet".to_string(), + message: format!("invalid column width entry for column {column}"), + }); + } + } + for (row, height) in &self.row_heights { + if *row == 0 || *height <= 0.0 { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: "cleanup_and_validate_sheet".to_string(), + message: format!("invalid row height entry for row {row}"), + }); + } + } + + self.merged_ranges.sort_by_key(|range| { + ( + range.start.row, + range.start.column, + range.end.row, + range.end.column, + ) + }); + self.merged_ranges.dedup(); + for index in 0..self.merged_ranges.len() { + for other in index + 1..self.merged_ranges.len() { + if self.merged_ranges[index].intersects(&self.merged_ranges[other]) { + return Err(SpreadsheetArtifactError::MergeConflict { + action: "cleanup_and_validate_sheet".to_string(), + range: self.merged_ranges[index].to_a1(), + conflict: self.merged_ranges[other].to_a1(), + }); + } + } + } + self.validate_tables("cleanup_and_validate_sheet")?; + self.validate_charts("cleanup_and_validate_sheet")?; + self.validate_pivot_tables("cleanup_and_validate_sheet")?; + Ok(()) + } + + fn ensure_range_write_allowed( + &self, + range: &CellRange, + allow_exact_merged_cell: bool, + action: &str, + ) -> Result<(), SpreadsheetArtifactError> { + for merged in &self.merged_ranges { + if !merged.intersects(range) { + continue; + } + if allow_exact_merged_cell && merged == range { + continue; + } + if merged.contains_range(range) || range.contains_range(merged) { + if allow_exact_merged_cell || merged != range { + return Err(SpreadsheetArtifactError::MergeConflict { + action: action.to_string(), + range: range.to_a1(), + conflict: merged.to_a1(), + }); + } + } else { + return Err(SpreadsheetArtifactError::MergeConflict { + action: action.to_string(), + range: range.to_a1(), + conflict: merged.to_a1(), + }); + } + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SpreadsheetArtifact { + pub artifact_id: String, + pub name: Option, + #[serde(default)] + pub sheets: Vec, + pub auto_recalculate: bool, + #[serde(default)] + pub text_styles: BTreeMap, + #[serde(default)] + pub fills: BTreeMap, + #[serde(default)] + pub borders: BTreeMap, + #[serde(default)] + pub number_formats: BTreeMap, + #[serde(default)] + pub cell_formats: BTreeMap, + #[serde(default)] + pub differential_formats: BTreeMap, +} + +impl SpreadsheetArtifact { + pub fn new(name: Option) -> Self { + Self { + artifact_id: format!("spreadsheet_{}", Uuid::new_v4().simple()), + name, + sheets: Vec::new(), + auto_recalculate: false, + text_styles: BTreeMap::new(), + fills: BTreeMap::new(), + borders: BTreeMap::new(), + number_formats: BTreeMap::new(), + cell_formats: BTreeMap::new(), + differential_formats: BTreeMap::new(), + } + } + + pub fn allowed_file_extensions() -> &'static [&'static str] { + &["xlsx", "json", "bin"] + } + + pub fn allowed_file_mime_types() -> &'static [&'static str] { + &[ + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/json", + "application/octet-stream", + ] + } + + pub fn allowed_file_types() -> &'static [SpreadsheetFileType] { + &[ + SpreadsheetFileType::Xlsx, + SpreadsheetFileType::Json, + SpreadsheetFileType::Binary, + ] + } + + pub fn get_output_file_name( + &self, + suffix: Option<&str>, + file_type: SpreadsheetFileType, + ) -> String { + let extension = match file_type { + SpreadsheetFileType::Xlsx => "xlsx", + SpreadsheetFileType::Json => "json", + SpreadsheetFileType::Binary => "bin", + }; + match suffix { + Some(suffix) => format!("{}_{}.{}", self.artifact_id, suffix, extension), + None => format!("{}.{}", self.artifact_id, extension), + } + } + + pub fn create_sheet( + &mut self, + name: String, + ) -> Result<&mut SpreadsheetSheet, SpreadsheetArtifactError> { + if name.trim().is_empty() { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: "create_sheet".to_string(), + message: "sheet name cannot be empty".to_string(), + }); + } + if self.sheets.iter().any(|sheet| sheet.name == name) { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: "create_sheet".to_string(), + message: format!("sheet `{name}` already exists"), + }); + } + self.sheets.push(SpreadsheetSheet::new(name)); + self.sheets + .last_mut() + .ok_or_else(|| SpreadsheetArtifactError::Serialization { + message: "created sheet was not available".to_string(), + }) + } + + pub fn get_sheet(&self, name: Option<&str>, index: Option) -> Option<&SpreadsheetSheet> { + if let Some(name) = name { + self.sheets.iter().find(|sheet| sheet.name == name) + } else if let Some(index) = index { + self.sheets.get(index) + } else { + None + } + } + + pub fn get_sheet_mut( + &mut self, + name: Option<&str>, + index: Option, + ) -> Option<&mut SpreadsheetSheet> { + if let Some(name) = name { + self.sheets.iter_mut().find(|sheet| sheet.name == name) + } else if let Some(index) = index { + self.sheets.get_mut(index) + } else { + None + } + } + + pub fn sheet_lookup( + &self, + action: &str, + name: Option<&str>, + index: Option, + ) -> Result<&SpreadsheetSheet, SpreadsheetArtifactError> { + self.get_sheet(name, index) + .ok_or_else(|| SpreadsheetArtifactError::SheetLookup { + action: action.to_string(), + message: match (name, index) { + (Some(name), _) => format!("sheet `{name}` was not found"), + (None, Some(index)) => format!("sheet index {index} was not found"), + (None, None) => "sheet name or index is required".to_string(), + }, + }) + } + + pub fn sheet_lookup_mut( + &mut self, + action: &str, + name: Option<&str>, + index: Option, + ) -> Result<&mut SpreadsheetSheet, SpreadsheetArtifactError> { + self.get_sheet_mut(name, index) + .ok_or_else(|| SpreadsheetArtifactError::SheetLookup { + action: action.to_string(), + message: match (name, index) { + (Some(name), _) => format!("sheet `{name}` was not found"), + (None, Some(index)) => format!("sheet index {index} was not found"), + (None, None) => "sheet name or index is required".to_string(), + }, + }) + } + + pub fn rename_sheet( + &mut self, + new_name: String, + old_name: Option<&str>, + index: Option, + ) -> Result<(), SpreadsheetArtifactError> { + if self.sheets.iter().any(|sheet| sheet.name == new_name) { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: "rename_sheet".to_string(), + message: format!("sheet `{new_name}` already exists"), + }); + } + let sheet = self.sheet_lookup_mut("rename_sheet", old_name, index)?; + sheet.name = new_name; + Ok(()) + } + + pub fn delete_sheet( + &mut self, + name: Option<&str>, + index: Option, + ) -> Result<(), SpreadsheetArtifactError> { + let remove_index = if let Some(name) = name { + self.sheets + .iter() + .position(|sheet| sheet.name == name) + .ok_or_else(|| SpreadsheetArtifactError::SheetLookup { + action: "delete_sheet".to_string(), + message: format!("sheet `{name}` was not found"), + })? + } else if let Some(index) = index { + if index >= self.sheets.len() { + return Err(SpreadsheetArtifactError::IndexOutOfRange { + action: "delete_sheet".to_string(), + index, + len: self.sheets.len(), + }); + } + index + } else { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: "delete_sheet".to_string(), + message: "sheet name or index is required".to_string(), + }); + }; + self.sheets.remove(remove_index); + Ok(()) + } + + pub fn list_sheet_names(&self) -> Vec { + self.sheets.iter().map(|sheet| sheet.name.clone()).collect() + } + + pub fn summary(&self) -> SpreadsheetSummary { + SpreadsheetSummary { + artifact_id: self.artifact_id.clone(), + sheets: self.sheets.iter().map(SpreadsheetSheet::summary).collect(), + size_bytes: self.to_bytes().len(), + } + } + + pub fn calculate(&mut self) { + recalculate_workbook(self); + } + + pub fn recalculate(&mut self) { + self.calculate(); + } + + pub fn to_dict(&self) -> Result { + serde_json::to_value(self).map_err(|error| SpreadsheetArtifactError::Serialization { + message: error.to_string(), + }) + } + + pub fn to_json(&self) -> Result { + serde_json::to_string_pretty(self).map_err(|error| { + SpreadsheetArtifactError::Serialization { + message: error.to_string(), + } + }) + } + + pub fn to_bytes(&self) -> Vec { + self.to_json() + .unwrap_or_else(|_| "{}".to_string()) + .into_bytes() + } + + pub fn to_bytes_base64(&self) -> String { + BASE64_STANDARD.encode(self.to_bytes()) + } + + pub fn from_dict( + data: Value, + artifact_id: Option, + ) -> Result { + let mut artifact: Self = serde_json::from_value(data).map_err(|error| { + SpreadsheetArtifactError::Serialization { + message: error.to_string(), + } + })?; + if let Some(artifact_id) = artifact_id { + artifact.artifact_id = artifact_id; + } else if artifact.artifact_id.is_empty() { + artifact.artifact_id = format!("spreadsheet_{}", Uuid::new_v4().simple()); + } + Ok(artifact) + } + + pub fn from_json( + json: impl AsRef<[u8]>, + artifact_id: Option, + ) -> Result { + let text = std::str::from_utf8(json.as_ref()).map_err(|error| { + SpreadsheetArtifactError::Serialization { + message: error.to_string(), + } + })?; + Self::from_dict( + serde_json::from_str(text).map_err(|error| { + SpreadsheetArtifactError::Serialization { + message: error.to_string(), + } + })?, + artifact_id, + ) + } + + pub fn from_bytes( + bytes: &[u8], + artifact_id: Option, + ) -> Result { + Self::from_json(bytes, artifact_id) + } + + pub fn from_source_file( + path: &Path, + artifact_id: Option, + ) -> Result { + match path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_ascii_lowercase() + .as_str() + { + "xlsx" => import_xlsx(path, artifact_id), + "json" => { + let bytes = std::fs::read(path).map_err(|error| { + SpreadsheetArtifactError::ImportFailed { + path: path.to_path_buf(), + message: error.to_string(), + } + })?; + Self::from_json(bytes, artifact_id) + } + "bin" => { + let bytes = std::fs::read(path).map_err(|error| { + SpreadsheetArtifactError::ImportFailed { + path: path.to_path_buf(), + message: error.to_string(), + } + })?; + Self::from_bytes(&bytes, artifact_id) + } + other => Err(SpreadsheetArtifactError::ImportFailed { + path: path.to_path_buf(), + message: format!("unsupported import file type `{other}`"), + }), + } + } + + pub fn read( + path: &Path, + artifact_id: Option, + ) -> Result { + Self::from_source_file(path, artifact_id) + } + + pub fn load( + path: &Path, + artifact_id: Option, + ) -> Result { + Self::from_source_file(path, artifact_id) + } + + pub fn save( + &mut self, + path: &Path, + file_type: Option<&str>, + ) -> Result { + self.to_source_file(path, file_type) + } + + pub fn export(&mut self, path: &Path) -> Result { + self.to_source_file(path, Some("xlsx")) + } + + pub fn to_source_file( + &mut self, + path: &Path, + file_type: Option<&str>, + ) -> Result { + let selected = file_type + .map(str::to_string) + .or_else(|| { + path.extension() + .and_then(|value| value.to_str()) + .map(str::to_string) + }) + .unwrap_or_else(|| "xlsx".to_string()) + .to_ascii_lowercase(); + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|error| { + SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + } + })?; + } + + match selected.as_str() { + "xlsx" => { + for sheet in &self.sheets { + if !sheet.charts.is_empty() + || !sheet.tables.is_empty() + || !sheet.conditional_formats.is_empty() + || !sheet.pivot_tables.is_empty() + { + return Err(SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: format!( + "xlsx export does not yet support charts, tables, conditional formats, or pivot tables on sheet `{}`; use json or bin export instead", + sheet.name + ), + }); + } + } + write_xlsx(self, path) + } + "json" => { + let json = self.to_json()?; + std::fs::write(path, json).map_err(|error| { + SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + } + })?; + Ok(path.to_path_buf()) + } + "bin" => { + std::fs::write(path, self.to_bytes()).map_err(|error| { + SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + } + })?; + Ok(path.to_path_buf()) + } + other => Err(SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: format!("unsupported export file type `{other}`"), + }), + } + } +} diff --git a/codex-rs/artifact-spreadsheet/src/pivot.rs b/codex-rs/artifact-spreadsheet/src/pivot.rs new file mode 100644 index 0000000000..c0c42985c9 --- /dev/null +++ b/codex-rs/artifact-spreadsheet/src/pivot.rs @@ -0,0 +1,177 @@ +use std::collections::BTreeMap; + +use serde::Deserialize; +use serde::Serialize; + +use crate::CellRange; +use crate::SpreadsheetArtifactError; +use crate::SpreadsheetCellRangeRef; +use crate::SpreadsheetSheet; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpreadsheetPivotFieldItem { + pub item_type: Option, + pub index: Option, + pub hidden: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpreadsheetPivotField { + pub index: u32, + pub name: Option, + pub axis: Option, + #[serde(default)] + pub items: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpreadsheetPivotFieldReference { + pub field_index: u32, + pub field_name: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpreadsheetPivotPageField { + pub field_index: u32, + pub field_name: Option, + pub selected_item: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpreadsheetPivotDataField { + pub field_index: u32, + pub field_name: Option, + pub name: Option, + pub subtotal: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpreadsheetPivotFilter { + pub field_index: Option, + pub field_name: Option, + pub filter_type: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpreadsheetPivotTable { + pub name: String, + pub cache_id: u32, + pub address: Option, + #[serde(default)] + pub row_fields: Vec, + #[serde(default)] + pub column_fields: Vec, + #[serde(default)] + pub page_fields: Vec, + #[serde(default)] + pub data_fields: Vec, + #[serde(default)] + pub filters: Vec, + #[serde(default)] + pub pivot_fields: Vec, + pub style_name: Option, + pub part_path: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct SpreadsheetPivotTableLookup<'a> { + pub name: Option<&'a str>, + pub index: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpreadsheetPivotCacheDefinition { + pub definition_path: String, + #[serde(default)] + pub field_names: Vec>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct SpreadsheetPivotPreservation { + #[serde(default)] + pub caches: BTreeMap, + #[serde(default)] + pub parts: BTreeMap, +} + +impl SpreadsheetPivotTable { + pub fn range(&self) -> Result, SpreadsheetArtifactError> { + self.address.as_deref().map(CellRange::parse).transpose() + } + + pub fn range_ref( + &self, + sheet_name: &str, + ) -> Result, SpreadsheetArtifactError> { + Ok(self + .range()? + .map(|range| SpreadsheetCellRangeRef::new(sheet_name.to_string(), &range))) + } +} + +impl SpreadsheetSheet { + pub fn list_pivot_tables( + &self, + range: Option<&CellRange>, + ) -> Result, SpreadsheetArtifactError> { + Ok(self + .pivot_tables + .iter() + .filter(|pivot_table| { + range.is_none_or(|target| { + pivot_table + .range() + .ok() + .flatten() + .is_some_and(|pivot_range| pivot_range.intersects(target)) + }) + }) + .cloned() + .collect()) + } + + pub fn get_pivot_table( + &self, + action: &str, + lookup: SpreadsheetPivotTableLookup, + ) -> Result<&SpreadsheetPivotTable, SpreadsheetArtifactError> { + if let Some(name) = lookup.name { + return self + .pivot_tables + .iter() + .find(|pivot_table| pivot_table.name == name) + .ok_or_else(|| SpreadsheetArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("pivot table `{name}` was not found"), + }); + } + if let Some(index) = lookup.index { + return self.pivot_tables.get(index).ok_or_else(|| { + SpreadsheetArtifactError::IndexOutOfRange { + action: action.to_string(), + index, + len: self.pivot_tables.len(), + } + }); + } + Err(SpreadsheetArtifactError::InvalidArgs { + action: action.to_string(), + message: "pivot table name or index is required".to_string(), + }) + } + + pub fn validate_pivot_tables(&self, action: &str) -> Result<(), SpreadsheetArtifactError> { + for pivot_table in &self.pivot_tables { + if pivot_table.name.trim().is_empty() { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: action.to_string(), + message: "pivot table name cannot be empty".to_string(), + }); + } + if let Some(address) = &pivot_table.address { + CellRange::parse(address)?; + } + } + Ok(()) + } +} diff --git a/codex-rs/artifact-spreadsheet/src/render.rs b/codex-rs/artifact-spreadsheet/src/render.rs new file mode 100644 index 0000000000..05e41b6083 --- /dev/null +++ b/codex-rs/artifact-spreadsheet/src/render.rs @@ -0,0 +1,373 @@ +use std::fs; +use std::path::Path; +use std::path::PathBuf; + +use serde::Deserialize; +use serde::Serialize; + +use crate::CellAddress; +use crate::CellRange; +use crate::SpreadsheetArtifact; +use crate::SpreadsheetArtifactError; +use crate::SpreadsheetSheet; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SpreadsheetRenderOptions { + pub output_path: Option, + pub center_address: Option, + pub width: Option, + pub height: Option, + pub include_headers: bool, + pub scale: f64, + pub performance_mode: bool, +} + +impl Default for SpreadsheetRenderOptions { + fn default() -> Self { + Self { + output_path: None, + center_address: None, + width: None, + height: None, + include_headers: true, + scale: 1.0, + performance_mode: false, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SpreadsheetRenderedOutput { + pub path: PathBuf, + pub html: String, +} + +impl SpreadsheetSheet { + pub fn render_html( + &self, + range: Option<&CellRange>, + options: &SpreadsheetRenderOptions, + ) -> Result { + let center = options + .center_address + .as_deref() + .map(CellAddress::parse) + .transpose()?; + let viewport = render_viewport(self, range, center, options)?; + let title = range + .map(CellRange::to_a1) + .unwrap_or_else(|| self.name.clone()); + Ok(format!( + concat!( + "", + "{}", + "", + "", + "
", + "

{}

{}

", + "
", + "{}
", + "
" + ), + html_escape(&title), + preview_css(), + html_escape(&self.name), + options.performance_mode, + html_escape(&title), + html_escape(&viewport.to_a1()), + viewport_style(options), + render_table(self, &viewport, options), + )) + } +} + +impl SpreadsheetArtifact { + pub fn render_workbook_previews( + &self, + cwd: &Path, + options: &SpreadsheetRenderOptions, + ) -> Result, SpreadsheetArtifactError> { + let sheets = if self.sheets.is_empty() { + vec![SpreadsheetSheet::new("Sheet1".to_string())] + } else { + self.sheets.clone() + }; + let output_paths = workbook_output_paths(self, cwd, options, &sheets); + sheets + .iter() + .zip(output_paths) + .map(|(sheet, path)| { + let html = sheet.render_html(None, options)?; + write_rendered_output(&path, &html)?; + Ok(SpreadsheetRenderedOutput { path, html }) + }) + .collect() + } + + pub fn render_sheet_preview( + &self, + cwd: &Path, + sheet: &SpreadsheetSheet, + options: &SpreadsheetRenderOptions, + ) -> Result { + let path = single_output_path( + cwd, + self, + options.output_path.as_deref(), + &format!("render_{}", sanitize_file_component(&sheet.name)), + ); + let html = sheet.render_html(None, options)?; + write_rendered_output(&path, &html)?; + Ok(SpreadsheetRenderedOutput { path, html }) + } + + pub fn render_range_preview( + &self, + cwd: &Path, + sheet: &SpreadsheetSheet, + range: &CellRange, + options: &SpreadsheetRenderOptions, + ) -> Result { + let path = single_output_path( + cwd, + self, + options.output_path.as_deref(), + &format!( + "render_{}_{}", + sanitize_file_component(&sheet.name), + sanitize_file_component(&range.to_a1()) + ), + ); + let html = sheet.render_html(Some(range), options)?; + write_rendered_output(&path, &html)?; + Ok(SpreadsheetRenderedOutput { path, html }) + } +} + +fn render_viewport( + sheet: &SpreadsheetSheet, + range: Option<&CellRange>, + center: Option, + options: &SpreadsheetRenderOptions, +) -> Result { + let base = range + .cloned() + .or_else(|| sheet.minimum_range()) + .unwrap_or_else(|| { + CellRange::from_start_end( + CellAddress { column: 1, row: 1 }, + CellAddress { column: 1, row: 1 }, + ) + }); + let Some(center) = center else { + return Ok(base); + }; + let visible_columns = options + .width + .map(|width| estimated_visible_count(width, 96.0, options.scale)) + .unwrap_or(base.width() as u32); + let visible_rows = options + .height + .map(|height| estimated_visible_count(height, 28.0, options.scale)) + .unwrap_or(base.height() as u32); + + let half_columns = visible_columns / 2; + let half_rows = visible_rows / 2; + let start_column = center + .column + .saturating_sub(half_columns) + .max(base.start.column); + let start_row = center.row.saturating_sub(half_rows).max(base.start.row); + let end_column = (start_column + visible_columns.saturating_sub(1)).min(base.end.column); + let end_row = (start_row + visible_rows.saturating_sub(1)).min(base.end.row); + Ok(CellRange::from_start_end( + CellAddress { + column: start_column, + row: start_row, + }, + CellAddress { + column: end_column.max(start_column), + row: end_row.max(start_row), + }, + )) +} + +fn estimated_visible_count(dimension: u32, cell_size: f64, scale: f64) -> u32 { + ((dimension as f64 / (cell_size * scale.max(0.1))).floor() as u32).max(1) +} + +fn render_table( + sheet: &SpreadsheetSheet, + range: &CellRange, + options: &SpreadsheetRenderOptions, +) -> String { + let mut rows = Vec::new(); + if options.include_headers { + let mut header = vec!["".to_string()]; + for column in range.start.column..=range.end.column { + header.push(format!( + "{}", + crate::column_index_to_letters(column) + )); + } + header.push("".to_string()); + rows.push(header.join("")); + } + for row in range.start.row..=range.end.row { + let mut cells = Vec::new(); + if options.include_headers { + cells.push(format!("{row}")); + } + for column in range.start.column..=range.end.column { + let address = CellAddress { column, row }; + let view = sheet.get_cell_view(address); + let value = view + .data + .as_ref() + .map(render_data_value) + .unwrap_or_default(); + cells.push(format!( + "{}", + address.to_a1(), + view.style_index, + html_escape(&value) + )); + } + rows.push(format!("{}", cells.join(""))); + } + rows.join("") +} + +fn render_data_value(value: &serde_json::Value) -> String { + match value { + serde_json::Value::String(value) => value.clone(), + serde_json::Value::Bool(value) => value.to_string(), + serde_json::Value::Number(value) => value.to_string(), + serde_json::Value::Null => String::new(), + other => other.to_string(), + } +} + +fn viewport_style(options: &SpreadsheetRenderOptions) -> String { + let mut style = vec![ + format!("--scale: {}", options.scale.max(0.1)), + format!( + "--headers: {}", + if options.include_headers { "1" } else { "0" } + ), + ]; + if let Some(width) = options.width { + style.push(format!("width: {width}px")); + } + if let Some(height) = options.height { + style.push(format!("height: {height}px")); + } + style.push("overflow: auto".to_string()); + style.join("; ") +} + +fn preview_css() -> &'static str { + concat!( + "body{margin:0;padding:24px;background:#f5f3ee;color:#1e1e1e;font-family:Georgia,serif;}", + ".spreadsheet-preview{display:flex;flex-direction:column;gap:16px;}", + "header h1{margin:0;font-size:24px;}header p{margin:0;color:#6b6257;font-size:13px;}", + ".viewport{border:1px solid #d6d0c7;background:#fff;box-shadow:0 12px 30px rgba(0,0,0,.08);}", + "table{border-collapse:collapse;transform:scale(var(--scale));transform-origin:top left;}", + "th,td{border:1px solid #ddd3c6;padding:6px 10px;min-width:72px;max-width:240px;font-size:13px;text-align:left;vertical-align:top;}", + "th{background:#f0ebe3;font-weight:600;position:sticky;top:0;z-index:1;}", + ".corner{background:#e7e0d6;left:0;z-index:2;}", + "td{white-space:pre-wrap;}" + ) +} + +fn write_rendered_output(path: &Path, html: &str) -> Result<(), SpreadsheetArtifactError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + } + fs::write(path, html).map_err(|error| SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + }) +} + +fn workbook_output_paths( + artifact: &SpreadsheetArtifact, + cwd: &Path, + options: &SpreadsheetRenderOptions, + sheets: &[SpreadsheetSheet], +) -> Vec { + if let Some(output_path) = options.output_path.as_deref() { + if output_path.extension().is_some_and(|ext| ext == "html") { + let stem = output_path + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("render"); + let parent = output_path.parent().unwrap_or(cwd); + return sheets + .iter() + .map(|sheet| { + parent.join(format!( + "{}_{}.html", + stem, + sanitize_file_component(&sheet.name) + )) + }) + .collect(); + } + return sheets + .iter() + .map(|sheet| output_path.join(format!("{}.html", sanitize_file_component(&sheet.name)))) + .collect(); + } + sheets + .iter() + .map(|sheet| { + cwd.join(format!( + "{}_render_{}.html", + artifact.artifact_id, + sanitize_file_component(&sheet.name) + )) + }) + .collect() +} + +fn single_output_path( + cwd: &Path, + artifact: &SpreadsheetArtifact, + output_path: Option<&Path>, + suffix: &str, +) -> PathBuf { + if let Some(output_path) = output_path { + return if output_path.extension().is_some_and(|ext| ext == "html") { + output_path.to_path_buf() + } else { + output_path.join(format!("{suffix}.html")) + }; + } + cwd.join(format!("{}_{}.html", artifact.artifact_id, suffix)) +} + +fn sanitize_file_component(value: &str) -> String { + value + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() { + character + } else { + '_' + } + }) + .collect() +} + +fn html_escape(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} diff --git a/codex-rs/artifact-spreadsheet/src/style.rs b/codex-rs/artifact-spreadsheet/src/style.rs new file mode 100644 index 0000000000..0c9a3782a5 --- /dev/null +++ b/codex-rs/artifact-spreadsheet/src/style.rs @@ -0,0 +1,580 @@ +use std::collections::BTreeMap; + +use serde::Deserialize; +use serde::Serialize; + +use crate::CellRange; +use crate::SpreadsheetArtifact; +use crate::SpreadsheetArtifactError; +use crate::SpreadsheetCellRangeRef; +use crate::SpreadsheetSheet; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct SpreadsheetFontFace { + pub font_family: Option, + pub font_scheme: Option, + pub typeface: Option, +} + +impl SpreadsheetFontFace { + fn merge(&self, patch: &Self) -> Self { + Self { + font_family: patch + .font_family + .clone() + .or_else(|| self.font_family.clone()), + font_scheme: patch + .font_scheme + .clone() + .or_else(|| self.font_scheme.clone()), + typeface: patch.typeface.clone().or_else(|| self.typeface.clone()), + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct SpreadsheetTextStyle { + pub bold: Option, + pub italic: Option, + pub underline: Option, + pub font_size: Option, + pub font_color: Option, + pub text_alignment: Option, + pub anchor: Option, + pub vertical_text_orientation: Option, + pub text_rotation: Option, + pub paragraph_spacing: Option, + pub bottom_inset: Option, + pub left_inset: Option, + pub right_inset: Option, + pub top_inset: Option, + pub font_family: Option, + pub font_scheme: Option, + pub typeface: Option, + pub font_face: Option, +} + +impl SpreadsheetTextStyle { + fn merge(&self, patch: &Self) -> Self { + Self { + bold: patch.bold.or(self.bold), + italic: patch.italic.or(self.italic), + underline: patch.underline.or(self.underline), + font_size: patch.font_size.or(self.font_size), + font_color: patch.font_color.clone().or_else(|| self.font_color.clone()), + text_alignment: patch + .text_alignment + .clone() + .or_else(|| self.text_alignment.clone()), + anchor: patch.anchor.clone().or_else(|| self.anchor.clone()), + vertical_text_orientation: patch + .vertical_text_orientation + .clone() + .or_else(|| self.vertical_text_orientation.clone()), + text_rotation: patch.text_rotation.or(self.text_rotation), + paragraph_spacing: patch.paragraph_spacing.or(self.paragraph_spacing), + bottom_inset: patch.bottom_inset.or(self.bottom_inset), + left_inset: patch.left_inset.or(self.left_inset), + right_inset: patch.right_inset.or(self.right_inset), + top_inset: patch.top_inset.or(self.top_inset), + font_family: patch + .font_family + .clone() + .or_else(|| self.font_family.clone()), + font_scheme: patch + .font_scheme + .clone() + .or_else(|| self.font_scheme.clone()), + typeface: patch.typeface.clone().or_else(|| self.typeface.clone()), + font_face: match (&self.font_face, &patch.font_face) { + (Some(base), Some(update)) => Some(base.merge(update)), + (None, Some(update)) => Some(update.clone()), + (Some(base), None) => Some(base.clone()), + (None, None) => None, + }, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SpreadsheetGradientStop { + pub position: f64, + pub color: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SpreadsheetFillRectangle { + pub left: f64, + pub right: f64, + pub top: f64, + pub bottom: f64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct SpreadsheetFill { + pub solid_fill_color: Option, + pub pattern_type: Option, + pub pattern_foreground_color: Option, + pub pattern_background_color: Option, + #[serde(default)] + pub color_transforms: Vec, + pub gradient_fill_type: Option, + #[serde(default)] + pub gradient_stops: Vec, + pub gradient_kind: Option, + pub angle: Option, + pub scaled: Option, + pub path_type: Option, + pub fill_rectangle: Option, + pub image_reference: Option, +} + +impl SpreadsheetFill { + fn merge(&self, patch: &Self) -> Self { + Self { + solid_fill_color: patch + .solid_fill_color + .clone() + .or_else(|| self.solid_fill_color.clone()), + pattern_type: patch + .pattern_type + .clone() + .or_else(|| self.pattern_type.clone()), + pattern_foreground_color: patch + .pattern_foreground_color + .clone() + .or_else(|| self.pattern_foreground_color.clone()), + pattern_background_color: patch + .pattern_background_color + .clone() + .or_else(|| self.pattern_background_color.clone()), + color_transforms: if patch.color_transforms.is_empty() { + self.color_transforms.clone() + } else { + patch.color_transforms.clone() + }, + gradient_fill_type: patch + .gradient_fill_type + .clone() + .or_else(|| self.gradient_fill_type.clone()), + gradient_stops: if patch.gradient_stops.is_empty() { + self.gradient_stops.clone() + } else { + patch.gradient_stops.clone() + }, + gradient_kind: patch + .gradient_kind + .clone() + .or_else(|| self.gradient_kind.clone()), + angle: patch.angle.or(self.angle), + scaled: patch.scaled.or(self.scaled), + path_type: patch.path_type.clone().or_else(|| self.path_type.clone()), + fill_rectangle: patch + .fill_rectangle + .clone() + .or_else(|| self.fill_rectangle.clone()), + image_reference: patch + .image_reference + .clone() + .or_else(|| self.image_reference.clone()), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct SpreadsheetBorderLine { + pub style: Option, + pub color: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct SpreadsheetBorder { + pub top: Option, + pub right: Option, + pub bottom: Option, + pub left: Option, +} + +impl SpreadsheetBorder { + fn merge(&self, patch: &Self) -> Self { + Self { + top: patch.top.clone().or_else(|| self.top.clone()), + right: patch.right.clone().or_else(|| self.right.clone()), + bottom: patch.bottom.clone().or_else(|| self.bottom.clone()), + left: patch.left.clone().or_else(|| self.left.clone()), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct SpreadsheetAlignment { + pub horizontal: Option, + pub vertical: Option, +} + +impl SpreadsheetAlignment { + fn merge(&self, patch: &Self) -> Self { + Self { + horizontal: patch.horizontal.clone().or_else(|| self.horizontal.clone()), + vertical: patch.vertical.clone().or_else(|| self.vertical.clone()), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct SpreadsheetNumberFormat { + pub format_id: Option, + pub format_code: Option, +} + +impl SpreadsheetNumberFormat { + fn merge(&self, patch: &Self) -> Self { + Self { + format_id: patch.format_id.or(self.format_id), + format_code: patch + .format_code + .clone() + .or_else(|| self.format_code.clone()), + } + } + + fn normalized(mut self) -> Self { + if self.format_code.is_none() { + self.format_code = self.format_id.and_then(builtin_number_format_code); + } + self + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct SpreadsheetCellFormat { + pub text_style_id: Option, + pub fill_id: Option, + pub border_id: Option, + pub alignment: Option, + pub number_format_id: Option, + pub wrap_text: Option, + pub base_cell_style_format_id: Option, +} + +impl SpreadsheetCellFormat { + pub fn wrap(mut self) -> Self { + self.wrap_text = Some(true); + self + } + + pub fn unwrap(mut self) -> Self { + self.wrap_text = Some(false); + self + } + + fn merge(&self, patch: &Self) -> Self { + Self { + text_style_id: patch.text_style_id.or(self.text_style_id), + fill_id: patch.fill_id.or(self.fill_id), + border_id: patch.border_id.or(self.border_id), + alignment: match (&self.alignment, &patch.alignment) { + (Some(base), Some(update)) => Some(base.merge(update)), + (None, Some(update)) => Some(update.clone()), + (Some(base), None) => Some(base.clone()), + (None, None) => None, + }, + number_format_id: patch.number_format_id.or(self.number_format_id), + wrap_text: patch.wrap_text.or(self.wrap_text), + base_cell_style_format_id: patch + .base_cell_style_format_id + .or(self.base_cell_style_format_id), + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct SpreadsheetDifferentialFormat { + pub text_style_id: Option, + pub fill_id: Option, + pub border_id: Option, + pub alignment: Option, + pub number_format_id: Option, + pub wrap_text: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SpreadsheetCellFormatSummary { + pub style_index: u32, + pub text_style: Option, + pub fill: Option, + pub border: Option, + pub alignment: Option, + pub number_format: Option, + pub wrap_text: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpreadsheetRangeFormat { + pub sheet_name: String, + pub range: String, +} + +impl SpreadsheetRangeFormat { + pub fn new(sheet_name: String, range: &CellRange) -> Self { + Self { + sheet_name, + range: range.to_a1(), + } + } + + pub fn range_ref(&self) -> Result { + let range = CellRange::parse(&self.range)?; + Ok(SpreadsheetCellRangeRef::new( + self.sheet_name.clone(), + &range, + )) + } + + pub fn top_left_style_index( + &self, + sheet: &SpreadsheetSheet, + ) -> Result { + self.range_ref()?.top_left_style_index(sheet) + } + + pub fn top_left_cell_format( + &self, + artifact: &SpreadsheetArtifact, + sheet: &SpreadsheetSheet, + ) -> Result, SpreadsheetArtifactError> { + let range = self.range_ref()?.range()?; + Ok(artifact.cell_format_summary(sheet.top_left_style_index(&range))) + } +} + +impl SpreadsheetArtifact { + pub fn create_text_style( + &mut self, + style: SpreadsheetTextStyle, + source_style_id: Option, + merge_with_existing_components: bool, + ) -> Result { + let created = if let Some(source_style_id) = source_style_id { + let source = self + .text_styles + .get(&source_style_id) + .cloned() + .ok_or_else(|| SpreadsheetArtifactError::InvalidArgs { + action: "create_text_style".to_string(), + message: format!("text style `{source_style_id}` was not found"), + })?; + if merge_with_existing_components { + source.merge(&style) + } else { + style + } + } else { + style + }; + Ok(insert_with_next_id(&mut self.text_styles, created)) + } + + pub fn get_text_style(&self, style_id: u32) -> Option<&SpreadsheetTextStyle> { + self.text_styles.get(&style_id) + } + + pub fn create_fill( + &mut self, + fill: SpreadsheetFill, + source_fill_id: Option, + merge_with_existing_components: bool, + ) -> Result { + let created = if let Some(source_fill_id) = source_fill_id { + let source = self.fills.get(&source_fill_id).cloned().ok_or_else(|| { + SpreadsheetArtifactError::InvalidArgs { + action: "create_fill".to_string(), + message: format!("fill `{source_fill_id}` was not found"), + } + })?; + if merge_with_existing_components { + source.merge(&fill) + } else { + fill + } + } else { + fill + }; + Ok(insert_with_next_id(&mut self.fills, created)) + } + + pub fn get_fill(&self, fill_id: u32) -> Option<&SpreadsheetFill> { + self.fills.get(&fill_id) + } + + pub fn create_border( + &mut self, + border: SpreadsheetBorder, + source_border_id: Option, + merge_with_existing_components: bool, + ) -> Result { + let created = if let Some(source_border_id) = source_border_id { + let source = self + .borders + .get(&source_border_id) + .cloned() + .ok_or_else(|| SpreadsheetArtifactError::InvalidArgs { + action: "create_border".to_string(), + message: format!("border `{source_border_id}` was not found"), + })?; + if merge_with_existing_components { + source.merge(&border) + } else { + border + } + } else { + border + }; + Ok(insert_with_next_id(&mut self.borders, created)) + } + + pub fn get_border(&self, border_id: u32) -> Option<&SpreadsheetBorder> { + self.borders.get(&border_id) + } + + pub fn create_number_format( + &mut self, + format: SpreadsheetNumberFormat, + source_number_format_id: Option, + merge_with_existing_components: bool, + ) -> Result { + let created = if let Some(source_number_format_id) = source_number_format_id { + let source = self + .number_formats + .get(&source_number_format_id) + .cloned() + .ok_or_else(|| SpreadsheetArtifactError::InvalidArgs { + action: "create_number_format".to_string(), + message: format!("number format `{source_number_format_id}` was not found"), + })?; + if merge_with_existing_components { + source.merge(&format) + } else { + format + } + } else { + format + }; + Ok(insert_with_next_id( + &mut self.number_formats, + created.normalized(), + )) + } + + pub fn get_number_format(&self, number_format_id: u32) -> Option<&SpreadsheetNumberFormat> { + self.number_formats.get(&number_format_id) + } + + pub fn create_cell_format( + &mut self, + format: SpreadsheetCellFormat, + source_format_id: Option, + merge_with_existing_components: bool, + ) -> Result { + let created = if let Some(source_format_id) = source_format_id { + let source = self + .cell_formats + .get(&source_format_id) + .cloned() + .ok_or_else(|| SpreadsheetArtifactError::InvalidArgs { + action: "create_cell_format".to_string(), + message: format!("cell format `{source_format_id}` was not found"), + })?; + if merge_with_existing_components { + source.merge(&format) + } else { + format + } + } else { + format + }; + Ok(insert_with_next_id(&mut self.cell_formats, created)) + } + + pub fn get_cell_format(&self, format_id: u32) -> Option<&SpreadsheetCellFormat> { + self.cell_formats.get(&format_id) + } + + pub fn create_differential_format(&mut self, format: SpreadsheetDifferentialFormat) -> u32 { + insert_with_next_id(&mut self.differential_formats, format) + } + + pub fn get_differential_format( + &self, + format_id: u32, + ) -> Option<&SpreadsheetDifferentialFormat> { + self.differential_formats.get(&format_id) + } + + pub fn resolve_cell_format(&self, style_index: u32) -> Option { + let format = self.cell_formats.get(&style_index)?.clone(); + resolve_cell_format_recursive(&self.cell_formats, &format, 0) + } + + pub fn cell_format_summary(&self, style_index: u32) -> Option { + let resolved = self.resolve_cell_format(style_index)?; + Some(SpreadsheetCellFormatSummary { + style_index, + text_style: resolved + .text_style_id + .and_then(|id| self.text_styles.get(&id).cloned()), + fill: resolved.fill_id.and_then(|id| self.fills.get(&id).cloned()), + border: resolved + .border_id + .and_then(|id| self.borders.get(&id).cloned()), + alignment: resolved.alignment, + number_format: resolved + .number_format_id + .and_then(|id| self.number_formats.get(&id).cloned()), + wrap_text: resolved.wrap_text, + }) + } +} + +impl SpreadsheetSheet { + pub fn range_format(&self, range: &CellRange) -> SpreadsheetRangeFormat { + SpreadsheetRangeFormat::new(self.name.clone(), range) + } +} + +fn insert_with_next_id(map: &mut BTreeMap, value: T) -> u32 { + let next_id = map.last_key_value().map(|(key, _)| key + 1).unwrap_or(1); + map.insert(next_id, value); + next_id +} + +fn resolve_cell_format_recursive( + cell_formats: &BTreeMap, + format: &SpreadsheetCellFormat, + depth: usize, +) -> Option { + if depth > 32 { + return None; + } + let base = format + .base_cell_style_format_id + .and_then(|id| cell_formats.get(&id)) + .and_then(|base| resolve_cell_format_recursive(cell_formats, base, depth + 1)); + Some(match base { + Some(base) => base.merge(format), + None => format.clone(), + }) +} + +fn builtin_number_format_code(format_id: u32) -> Option { + match format_id { + 0 => Some("General".to_string()), + 1 => Some("0".to_string()), + 2 => Some("0.00".to_string()), + 3 => Some("#,##0".to_string()), + 4 => Some("#,##0.00".to_string()), + 9 => Some("0%".to_string()), + 10 => Some("0.00%".to_string()), + _ => None, + } +} diff --git a/codex-rs/artifact-spreadsheet/src/table.rs b/codex-rs/artifact-spreadsheet/src/table.rs new file mode 100644 index 0000000000..be04c188e2 --- /dev/null +++ b/codex-rs/artifact-spreadsheet/src/table.rs @@ -0,0 +1,630 @@ +use std::collections::BTreeMap; +use std::collections::BTreeSet; + +use serde::Deserialize; +use serde::Serialize; + +use crate::CellAddress; +use crate::CellRange; +use crate::SpreadsheetArtifactError; +use crate::SpreadsheetCellValue; +use crate::SpreadsheetSheet; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SpreadsheetTableColumn { + pub id: u32, + pub name: String, + pub totals_row_label: Option, + pub totals_row_function: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SpreadsheetTable { + pub id: u32, + pub name: String, + pub display_name: String, + pub range: String, + pub header_row_count: u32, + pub totals_row_count: u32, + pub style_name: Option, + pub show_first_column: bool, + pub show_last_column: bool, + pub show_row_stripes: bool, + pub show_column_stripes: bool, + #[serde(default)] + pub columns: Vec, + #[serde(default)] + pub filters: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SpreadsheetTableView { + pub id: u32, + pub name: String, + pub display_name: String, + pub address: String, + pub full_range: String, + pub header_row_count: u32, + pub totals_row_count: u32, + pub totals_row_visible: bool, + pub header_row_range: Option, + pub data_body_range: Option, + pub totals_row_range: Option, + pub style_name: Option, + pub show_first_column: bool, + pub show_last_column: bool, + pub show_row_stripes: bool, + pub show_column_stripes: bool, + pub columns: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct SpreadsheetTableLookup<'a> { + pub name: Option<&'a str>, + pub display_name: Option<&'a str>, + pub id: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpreadsheetCreateTableOptions { + pub name: Option, + pub display_name: Option, + pub header_row_count: u32, + pub totals_row_count: u32, + pub style_name: Option, + pub show_first_column: bool, + pub show_last_column: bool, + pub show_row_stripes: bool, + pub show_column_stripes: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpreadsheetTableStyleOptions { + pub style_name: Option, + pub show_first_column: Option, + pub show_last_column: Option, + pub show_row_stripes: Option, + pub show_column_stripes: Option, +} + +impl SpreadsheetTable { + pub fn range(&self) -> Result { + CellRange::parse(&self.range) + } + + pub fn address(&self) -> String { + self.range.clone() + } + + pub fn full_range(&self) -> String { + self.range.clone() + } + + pub fn totals_row_visible(&self) -> bool { + self.totals_row_count > 0 + } + + pub fn header_row_range(&self) -> Result, SpreadsheetArtifactError> { + if self.header_row_count == 0 { + return Ok(None); + } + let range = self.range()?; + Ok(Some(CellRange::from_start_end( + range.start, + CellAddress { + column: range.end.column, + row: range.start.row + self.header_row_count - 1, + }, + ))) + } + + pub fn data_body_range(&self) -> Result, SpreadsheetArtifactError> { + let range = self.range()?; + let start_row = range.start.row + self.header_row_count; + let end_row = range.end.row.saturating_sub(self.totals_row_count); + if start_row > end_row { + return Ok(None); + } + Ok(Some(CellRange::from_start_end( + CellAddress { + column: range.start.column, + row: start_row, + }, + CellAddress { + column: range.end.column, + row: end_row, + }, + ))) + } + + pub fn totals_row_range(&self) -> Result, SpreadsheetArtifactError> { + if self.totals_row_count == 0 { + return Ok(None); + } + let range = self.range()?; + Ok(Some(CellRange::from_start_end( + CellAddress { + column: range.start.column, + row: range.end.row - self.totals_row_count + 1, + }, + range.end, + ))) + } + + pub fn view(&self) -> Result { + Ok(SpreadsheetTableView { + id: self.id, + name: self.name.clone(), + display_name: self.display_name.clone(), + address: self.address(), + full_range: self.full_range(), + header_row_count: self.header_row_count, + totals_row_count: self.totals_row_count, + totals_row_visible: self.totals_row_visible(), + header_row_range: self.header_row_range()?.map(|range| range.to_a1()), + data_body_range: self.data_body_range()?.map(|range| range.to_a1()), + totals_row_range: self.totals_row_range()?.map(|range| range.to_a1()), + style_name: self.style_name.clone(), + show_first_column: self.show_first_column, + show_last_column: self.show_last_column, + show_row_stripes: self.show_row_stripes, + show_column_stripes: self.show_column_stripes, + columns: self.columns.clone(), + }) + } +} + +impl SpreadsheetSheet { + pub fn create_table( + &mut self, + action: &str, + range: &CellRange, + options: SpreadsheetCreateTableOptions, + ) -> Result { + validate_table_geometry( + action, + range, + options.header_row_count, + options.totals_row_count, + )?; + for table in &self.tables { + let table_range = table.range()?; + if table_range.intersects(range) { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: action.to_string(), + message: format!( + "table range `{}` intersects existing table `{}`", + range.to_a1(), + table.name + ), + }); + } + } + + let next_id = self.tables.iter().map(|table| table.id).max().unwrap_or(0) + 1; + let name = options.name.unwrap_or_else(|| format!("Table{next_id}")); + if name.trim().is_empty() { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: action.to_string(), + message: "table name cannot be empty".to_string(), + }); + } + let display_name = options.display_name.unwrap_or_else(|| name.clone()); + if display_name.trim().is_empty() { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: action.to_string(), + message: "table display_name cannot be empty".to_string(), + }); + } + ensure_unique_table_name(&self.tables, action, &name, &display_name, None)?; + + let columns = build_table_columns(self, range, options.header_row_count); + self.tables.push(SpreadsheetTable { + id: next_id, + name, + display_name, + range: range.to_a1(), + header_row_count: options.header_row_count, + totals_row_count: options.totals_row_count, + style_name: options.style_name, + show_first_column: options.show_first_column, + show_last_column: options.show_last_column, + show_row_stripes: options.show_row_stripes, + show_column_stripes: options.show_column_stripes, + columns, + filters: BTreeMap::new(), + }); + Ok(next_id) + } + + pub fn list_tables( + &self, + range: Option<&CellRange>, + ) -> Result, SpreadsheetArtifactError> { + self.tables + .iter() + .filter(|table| { + range.is_none_or(|target| { + table + .range() + .map(|table_range| table_range.intersects(target)) + .unwrap_or(false) + }) + }) + .map(SpreadsheetTable::view) + .collect() + } + + pub fn get_table( + &self, + action: &str, + lookup: SpreadsheetTableLookup<'_>, + ) -> Result<&SpreadsheetTable, SpreadsheetArtifactError> { + self.table_lookup_internal(action, lookup) + } + + pub fn get_table_view( + &self, + action: &str, + lookup: SpreadsheetTableLookup<'_>, + ) -> Result { + self.get_table(action, lookup)?.view() + } + + pub fn delete_table( + &mut self, + action: &str, + lookup: SpreadsheetTableLookup<'_>, + ) -> Result<(), SpreadsheetArtifactError> { + let index = self.table_index(action, lookup)?; + self.tables.remove(index); + Ok(()) + } + + pub fn set_table_style( + &mut self, + action: &str, + lookup: SpreadsheetTableLookup<'_>, + options: SpreadsheetTableStyleOptions, + ) -> Result<(), SpreadsheetArtifactError> { + let table = self.table_lookup_mut(action, lookup)?; + table.style_name = options.style_name; + if let Some(value) = options.show_first_column { + table.show_first_column = value; + } + if let Some(value) = options.show_last_column { + table.show_last_column = value; + } + if let Some(value) = options.show_row_stripes { + table.show_row_stripes = value; + } + if let Some(value) = options.show_column_stripes { + table.show_column_stripes = value; + } + Ok(()) + } + + pub fn clear_table_filters( + &mut self, + action: &str, + lookup: SpreadsheetTableLookup<'_>, + ) -> Result<(), SpreadsheetArtifactError> { + self.table_lookup_mut(action, lookup)?.filters.clear(); + Ok(()) + } + + pub fn reapply_table_filters( + &mut self, + action: &str, + lookup: SpreadsheetTableLookup<'_>, + ) -> Result<(), SpreadsheetArtifactError> { + let _ = self.table_lookup_mut(action, lookup)?; + Ok(()) + } + + pub fn rename_table_column( + &mut self, + action: &str, + lookup: SpreadsheetTableLookup<'_>, + column_id: Option, + column_name: Option<&str>, + new_name: String, + ) -> Result { + if new_name.trim().is_empty() { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: action.to_string(), + message: "table column name cannot be empty".to_string(), + }); + } + let table = self.table_lookup_mut(action, lookup)?; + if table + .columns + .iter() + .any(|column| column.name == new_name && Some(column.id) != column_id) + { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("table column `{new_name}` already exists"), + }); + } + let column = table_column_lookup_mut(&mut table.columns, action, column_id, column_name)?; + column.name = new_name; + Ok(column.clone()) + } + + pub fn set_table_column_totals( + &mut self, + action: &str, + lookup: SpreadsheetTableLookup<'_>, + column_id: Option, + column_name: Option<&str>, + totals_row_label: Option, + totals_row_function: Option, + ) -> Result { + let table = self.table_lookup_mut(action, lookup)?; + let column = table_column_lookup_mut(&mut table.columns, action, column_id, column_name)?; + column.totals_row_label = totals_row_label; + column.totals_row_function = totals_row_function; + Ok(column.clone()) + } + + pub fn validate_tables(&self, action: &str) -> Result<(), SpreadsheetArtifactError> { + let mut seen_names = BTreeSet::new(); + let mut seen_display_names = BTreeSet::new(); + for table in &self.tables { + let range = table.range()?; + validate_table_geometry( + action, + &range, + table.header_row_count, + table.totals_row_count, + )?; + if !seen_names.insert(table.name.clone()) { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("duplicate table name `{}`", table.name), + }); + } + if !seen_display_names.insert(table.display_name.clone()) { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("duplicate table display_name `{}`", table.display_name), + }); + } + let column_names = table + .columns + .iter() + .map(|column| column.name.clone()) + .collect::>(); + if column_names.len() != table.columns.len() { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("table `{}` has duplicate column names", table.name), + }); + } + } + + for index in 0..self.tables.len() { + for other in index + 1..self.tables.len() { + if self.tables[index] + .range()? + .intersects(&self.tables[other].range()?) + { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: action.to_string(), + message: format!( + "table `{}` intersects table `{}`", + self.tables[index].name, self.tables[other].name + ), + }); + } + } + } + Ok(()) + } + + fn table_index( + &self, + action: &str, + lookup: SpreadsheetTableLookup<'_>, + ) -> Result { + self.tables + .iter() + .position(|table| table_matches_lookup(table, lookup.clone())) + .ok_or_else(|| SpreadsheetArtifactError::InvalidArgs { + action: action.to_string(), + message: describe_missing_table(lookup), + }) + } + + fn table_lookup_internal( + &self, + action: &str, + lookup: SpreadsheetTableLookup<'_>, + ) -> Result<&SpreadsheetTable, SpreadsheetArtifactError> { + self.tables + .iter() + .find(|table| table_matches_lookup(table, lookup.clone())) + .ok_or_else(|| SpreadsheetArtifactError::InvalidArgs { + action: action.to_string(), + message: describe_missing_table(lookup), + }) + } + + fn table_lookup_mut( + &mut self, + action: &str, + lookup: SpreadsheetTableLookup<'_>, + ) -> Result<&mut SpreadsheetTable, SpreadsheetArtifactError> { + let index = self.table_index(action, lookup)?; + Ok(&mut self.tables[index]) + } +} + +fn table_matches_lookup(table: &SpreadsheetTable, lookup: SpreadsheetTableLookup<'_>) -> bool { + if let Some(name) = lookup.name { + table.name == name + } else if let Some(display_name) = lookup.display_name { + table.display_name == display_name + } else if let Some(id) = lookup.id { + table.id == id + } else { + false + } +} + +fn describe_missing_table(lookup: SpreadsheetTableLookup<'_>) -> String { + if let Some(name) = lookup.name { + format!("table name `{name}` was not found") + } else if let Some(display_name) = lookup.display_name { + format!("table display_name `{display_name}` was not found") + } else if let Some(id) = lookup.id { + format!("table id `{id}` was not found") + } else { + "table name, display_name, or id is required".to_string() + } +} + +fn ensure_unique_table_name( + tables: &[SpreadsheetTable], + action: &str, + name: &str, + display_name: &str, + exclude_id: Option, +) -> Result<(), SpreadsheetArtifactError> { + if tables.iter().any(|table| { + Some(table.id) != exclude_id && (table.name == name || table.display_name == name) + }) { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("table name `{name}` already exists"), + }); + } + if tables.iter().any(|table| { + Some(table.id) != exclude_id + && (table.display_name == display_name || table.name == display_name) + }) { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("table display_name `{display_name}` already exists"), + }); + } + Ok(()) +} + +fn validate_table_geometry( + action: &str, + range: &CellRange, + header_row_count: u32, + totals_row_count: u32, +) -> Result<(), SpreadsheetArtifactError> { + if range.width() == 0 { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: action.to_string(), + message: "table range must include at least one column".to_string(), + }); + } + if header_row_count + totals_row_count > range.height() as u32 { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: action.to_string(), + message: "table range is smaller than header and totals rows".to_string(), + }); + } + Ok(()) +} + +fn build_table_columns( + sheet: &SpreadsheetSheet, + range: &CellRange, + header_row_count: u32, +) -> Vec { + let header_row = range.start.row + header_row_count.saturating_sub(1); + let default_names = (0..range.width()) + .map(|index| format!("Column{}", index + 1)) + .collect::>(); + let names = unique_table_column_names( + (range.start.column..=range.end.column) + .enumerate() + .map(|(index, column)| { + if header_row_count == 0 { + return default_names[index].clone(); + } + sheet + .get_cell(CellAddress { + column, + row: header_row, + }) + .and_then(|cell| cell.value.as_ref()) + .map(cell_value_to_table_header) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| default_names[index].clone()) + }) + .collect::>(), + ); + names + .into_iter() + .enumerate() + .map(|(index, name)| SpreadsheetTableColumn { + id: index as u32 + 1, + name, + totals_row_label: None, + totals_row_function: None, + }) + .collect() +} + +fn unique_table_column_names(names: Vec) -> Vec { + let mut seen = BTreeMap::::new(); + names + .into_iter() + .map(|name| { + let entry = seen.entry(name.clone()).or_insert(0); + *entry += 1; + if *entry == 1 { + name + } else { + format!("{name}_{}", *entry) + } + }) + .collect() +} + +fn cell_value_to_table_header(value: &SpreadsheetCellValue) -> String { + match value { + SpreadsheetCellValue::Bool(value) => value.to_string(), + SpreadsheetCellValue::Integer(value) => value.to_string(), + SpreadsheetCellValue::Float(value) => value.to_string(), + SpreadsheetCellValue::String(value) + | SpreadsheetCellValue::DateTime(value) + | SpreadsheetCellValue::Error(value) => value.clone(), + } +} + +fn table_column_lookup_mut<'a>( + columns: &'a mut [SpreadsheetTableColumn], + action: &str, + column_id: Option, + column_name: Option<&str>, +) -> Result<&'a mut SpreadsheetTableColumn, SpreadsheetArtifactError> { + columns + .iter_mut() + .find(|column| { + if let Some(column_id) = column_id { + column.id == column_id + } else if let Some(column_name) = column_name { + column.name == column_name + } else { + false + } + }) + .ok_or_else(|| SpreadsheetArtifactError::InvalidArgs { + action: action.to_string(), + message: if let Some(column_id) = column_id { + format!("table column id `{column_id}` was not found") + } else if let Some(column_name) = column_name { + format!("table column `{column_name}` was not found") + } else { + "table column id or name is required".to_string() + }, + }) +} diff --git a/codex-rs/artifact-spreadsheet/src/tests.rs b/codex-rs/artifact-spreadsheet/src/tests.rs new file mode 100644 index 0000000000..608ac7b392 --- /dev/null +++ b/codex-rs/artifact-spreadsheet/src/tests.rs @@ -0,0 +1,1228 @@ +use std::collections::BTreeMap; + +use pretty_assertions::assert_eq; + +use crate::CellAddress; +use crate::CellRange; +use crate::SpreadsheetArtifact; +use crate::SpreadsheetArtifactManager; +use crate::SpreadsheetArtifactRequest; +use crate::SpreadsheetCell; +use crate::SpreadsheetCellFormat; +use crate::SpreadsheetCellFormatSummary; +use crate::SpreadsheetCellValue; +use crate::SpreadsheetFileType; +use crate::SpreadsheetFill; +use crate::SpreadsheetFontFace; +use crate::SpreadsheetNumberFormat; +use crate::SpreadsheetRenderOptions; +use crate::SpreadsheetSheet; +use crate::SpreadsheetSheetReference; +use crate::SpreadsheetTextStyle; + +#[test] +fn manager_can_create_edit_recalculate_and_export() -> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = SpreadsheetArtifactManager::default(); + + let created = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Budget" }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id; + + manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "create_sheet".to_string(), + args: serde_json::json!({ "name": "Sheet1" }), + }, + temp_dir.path(), + )?; + + manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "set_range_values".to_string(), + args: serde_json::json!({ + "sheet_name": "Sheet1", + "range": "A1:B2", + "values": [[1, 2], [3, 4]] + }), + }, + temp_dir.path(), + )?; + + manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "set_cell_formula".to_string(), + args: serde_json::json!({ + "sheet_name": "Sheet1", + "address": "C1", + "formula": "=SUM(A1:B2)", + "recalculate": true + }), + }, + temp_dir.path(), + )?; + + let cell = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "get_cell".to_string(), + args: serde_json::json!({ + "sheet_name": "Sheet1", + "address": "C1" + }), + }, + temp_dir.path(), + )?; + assert_eq!( + cell.cell.and_then(|entry| entry.value), + Some(SpreadsheetCellValue::Integer(10)) + ); + + let export_path = temp_dir.path().join("budget.xlsx"); + let export = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id), + action: "export_xlsx".to_string(), + args: serde_json::json!({ "path": export_path }), + }, + temp_dir.path(), + )?; + assert_eq!(export.exported_paths.len(), 1); + assert!(export.exported_paths[0].exists()); + Ok(()) +} + +#[test] +fn spreadsheet_serialization_roundtrip_preserves_cells() -> Result<(), Box> { + let mut artifact = SpreadsheetArtifact::new(Some("Roundtrip".to_string())); + let sheet = artifact.create_sheet("Sheet1".to_string())?; + sheet.set_value( + crate::CellAddress::parse("A1")?, + Some(SpreadsheetCellValue::String("hello".to_string())), + )?; + sheet.set_formula(crate::CellAddress::parse("B1")?, Some("=A1".to_string()))?; + artifact.recalculate(); + + let json = artifact.to_json()?; + let restored = SpreadsheetArtifact::from_json(json, None)?; + let restored_sheet = restored.get_sheet(Some("Sheet1"), None).expect("sheet"); + let cell = restored_sheet.get_cell_view(crate::CellAddress::parse("A1")?); + assert_eq!( + cell.value, + Some(SpreadsheetCellValue::String("hello".to_string())) + ); + Ok(()) +} + +#[test] +fn xlsx_roundtrip_preserves_merged_ranges_and_style_indices() +-> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let path = temp_dir.path().join("styled.xlsx"); + + let mut artifact = SpreadsheetArtifact::new(Some("Styled".to_string())); + let sheet = artifact.create_sheet("Sheet1".to_string())?; + sheet.set_value( + crate::CellAddress::parse("A1")?, + Some(SpreadsheetCellValue::Integer(42)), + )?; + sheet.set_style_index(&crate::CellRange::parse("A1:B1")?, 3)?; + sheet.merge_cells(&crate::CellRange::parse("A1:B1")?, true)?; + artifact.export(&path)?; + + let restored = SpreadsheetArtifact::from_source_file(&path, None)?; + let restored_sheet = restored.get_sheet(Some("Sheet1"), None).expect("sheet"); + assert_eq!(restored_sheet.merged_ranges.len(), 1); + assert_eq!( + restored_sheet + .get_cell_view(crate::CellAddress::parse("A1")?) + .style_index, + 3 + ); + Ok(()) +} + +#[test] +fn path_accesses_cover_import_and_export() -> Result<(), Box> { + let cwd = tempfile::tempdir()?; + let request = crate::SpreadsheetArtifactRequest { + artifact_id: Some("spreadsheet_1".to_string()), + action: "export_xlsx".to_string(), + args: serde_json::json!({ "path": "out/report.xlsx" }), + }; + let accesses = request.required_path_accesses(cwd.path())?; + assert_eq!(accesses.len(), 1); + assert!(accesses[0].path.ends_with("out/report.xlsx")); + Ok(()) +} + +#[test] +fn render_options_write_deterministic_html_previews() -> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut artifact = SpreadsheetArtifact::new(Some("Preview".to_string())); + artifact.create_sheet("Sheet 1".to_string())?; + { + let sheet = artifact + .get_sheet_mut(Some("Sheet 1"), None) + .expect("sheet"); + sheet.set_value( + CellAddress::parse("A1")?, + Some(SpreadsheetCellValue::String("Name".to_string())), + )?; + sheet.set_value( + CellAddress::parse("B1")?, + Some(SpreadsheetCellValue::String("Value".to_string())), + )?; + sheet.set_value( + CellAddress::parse("A2")?, + Some(SpreadsheetCellValue::String("Alpha".to_string())), + )?; + sheet.set_value( + CellAddress::parse("B2")?, + Some(SpreadsheetCellValue::Integer(42)), + )?; + } + + let rendered = artifact.render_range_preview( + temp_dir.path(), + artifact.get_sheet(Some("Sheet 1"), None).expect("sheet"), + &CellRange::parse("A1:B2")?, + &SpreadsheetRenderOptions { + output_path: Some(temp_dir.path().join("range-preview.html")), + width: Some(320), + height: Some(200), + include_headers: true, + scale: 1.25, + performance_mode: true, + ..Default::default() + }, + )?; + assert!(rendered.path.exists()); + assert_eq!(std::fs::read_to_string(&rendered.path)?, rendered.html); + assert!(rendered.html.contains("")); + assert!(rendered.html.contains("data-performance-mode=\"true\"")); + assert!(rendered.html.contains( + "style=\"--scale: 1.25; --headers: 1; width: 320px; height: 200px; overflow: auto\"" + )); + assert!(rendered.html.contains("A")); + assert!(rendered.html.contains("data-address=\"B2\"")); + assert!(rendered.html.contains(">42")); + + let workbook = artifact.render_workbook_previews( + temp_dir.path(), + &SpreadsheetRenderOptions { + output_path: Some(temp_dir.path().join("workbook")), + include_headers: false, + ..Default::default() + }, + )?; + assert_eq!(workbook.len(), 1); + assert!(workbook[0].path.ends_with("Sheet_1.html")); + assert!(!workbook[0].html.contains("A")); + Ok(()) +} + +#[test] +fn sheet_refs_support_handle_and_field_apis() -> Result<(), Box> { + let mut artifact = SpreadsheetArtifact::new(Some("Handles".to_string())); + let (range_ref, cell_ref) = { + let sheet = artifact.create_sheet("Sheet1".to_string())?; + let range_ref = sheet.range_ref("A1:B2")?; + range_ref.set_value(sheet, Some(SpreadsheetCellValue::Integer(7)))?; + let cell_ref = sheet.cell_ref("B2")?; + cell_ref.set_formula(sheet, Some("=SUM(A1:B2)".to_string()))?; + (range_ref, cell_ref) + }; + artifact.recalculate(); + let sheet = artifact.get_sheet(Some("Sheet1"), None).expect("sheet"); + + let values = range_ref.get_values(sheet)?; + assert_eq!(values[0][0], Some(SpreadsheetCellValue::Integer(7))); + assert_eq!( + cell_ref.get(sheet)?.value, + Some(SpreadsheetCellValue::Integer(28)) + ); + assert_eq!( + sheet.get_cell_field_by_indices(2, 2, "formula")?, + Some(serde_json::Value::String("=SUM(A1:B2)".to_string())) + ); + assert_eq!( + sheet.minimum_range_ref().map(|entry| entry.address), + Some("A1:B2".to_string()) + ); + assert!(matches!( + sheet.to_dict()?, + serde_json::Value::Object(_) | serde_json::Value::Array(_) + )); + Ok(()) +} + +#[test] +fn manager_supports_single_value_formula_and_cite_cell_actions() +-> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = SpreadsheetArtifactManager::default(); + let created = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Actions" }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id; + + manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "create_sheet".to_string(), + args: serde_json::json!({ "name": "Sheet1" }), + }, + temp_dir.path(), + )?; + + let uniform = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "set_range_value".to_string(), + args: serde_json::json!({ + "sheet_name": "Sheet1", + "range": "A1:B2", + "value": 5 + }), + }, + temp_dir.path(), + )?; + assert_eq!( + uniform + .range_ref + .as_ref() + .map(|entry| entry.address.clone()), + Some("A1:B2".to_string()) + ); + + manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "set_range_formula".to_string(), + args: serde_json::json!({ + "sheet_name": "Sheet1", + "range": "C1:C2", + "formula": "=SUM(A1:B2)", + "recalculate": true + }), + }, + temp_dir.path(), + )?; + + let cited = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "cite_cell".to_string(), + args: serde_json::json!({ + "sheet_name": "Sheet1", + "address": "C1", + "tether_id": "source-1", + "start_line": 3, + "end_line": 8 + }), + }, + temp_dir.path(), + )?; + assert_eq!( + cited.cell.as_ref().map(|entry| entry.citations.len()), + Some(1) + ); + + let by_indices = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "get_cell_by_indices".to_string(), + args: serde_json::json!({ + "sheet_name": "Sheet1", + "column_index": 3, + "row_index": 1 + }), + }, + temp_dir.path(), + )?; + assert_eq!( + by_indices + .cell + .as_ref() + .and_then(|entry| entry.value.clone()), + Some(SpreadsheetCellValue::Integer(20)) + ); + + let field = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id), + action: "get_cell_field".to_string(), + args: serde_json::json!({ + "sheet_name": "Sheet1", + "address": "C1", + "field": "formula" + }), + }, + temp_dir.path(), + )?; + assert_eq!( + field.cell_field, + Some(serde_json::Value::String("=SUM(A1:B2)".to_string())) + ); + Ok(()) +} + +#[test] +fn artifact_file_type_helpers_and_source_files_work() -> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut artifact = SpreadsheetArtifact::new(Some("Files".to_string())); + artifact.artifact_id = "spreadsheet_fixed".to_string(); + artifact.create_sheet("Sheet1".to_string())?.set_value( + CellAddress::parse("A1")?, + Some(SpreadsheetCellValue::String("hello".to_string())), + )?; + + assert_eq!( + SpreadsheetArtifact::allowed_file_extensions(), + &["xlsx", "json", "bin"] + ); + assert_eq!( + SpreadsheetArtifact::allowed_file_mime_types(), + &[ + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/json", + "application/octet-stream", + ] + ); + assert_eq!( + SpreadsheetArtifact::allowed_file_types().to_vec(), + vec![ + SpreadsheetFileType::Xlsx, + SpreadsheetFileType::Json, + SpreadsheetFileType::Binary, + ] + ); + assert_eq!( + artifact.get_output_file_name(Some("preview"), SpreadsheetFileType::Json), + "spreadsheet_fixed_preview.json".to_string() + ); + + let json_path = temp_dir + .path() + .join(artifact.get_output_file_name(None, SpreadsheetFileType::Json)); + artifact.save(&json_path, Some("json"))?; + let restored_json = SpreadsheetArtifact::load(&json_path, None)?; + assert_eq!(restored_json.to_dict()?, artifact.to_dict()?); + + let bytes_path = temp_dir + .path() + .join(artifact.get_output_file_name(Some("bytes"), SpreadsheetFileType::Binary)); + artifact.save(&bytes_path, Some("bin"))?; + let restored_bytes = SpreadsheetArtifact::read(&bytes_path, None)?; + assert_eq!(restored_bytes.to_dict()?, artifact.to_dict()?); + Ok(()) +} + +#[test] +fn sheet_cleanup_and_row_sizing_helpers_work() -> Result<(), Box> { + let mut sheet = SpreadsheetSheet::new("Sheet1".to_string()); + sheet.default_row_height = Some(15.0); + sheet.set_column_widths_bulk(&BTreeMap::from([ + ("A".to_string(), 12.0), + ("C:D".to_string(), 20.0), + ]))?; + sheet.set_row_height(2, Some(18.0))?; + sheet.set_row_heights(3, 4, Some(22.0))?; + sheet.set_row_heights_bulk(&BTreeMap::from([(4, None), (5, Some(30.0))]))?; + + assert_eq!(sheet.get_column_width("A")?, Some(12.0)); + assert_eq!(sheet.get_column_width("B")?, None); + assert_eq!(sheet.get_column_width("D")?, Some(20.0)); + assert_eq!(sheet.get_row_height(2), Some(18.0)); + assert_eq!(sheet.get_row_height(3), Some(22.0)); + assert_eq!(sheet.get_row_height(4), Some(15.0)); + assert_eq!(sheet.get_row_height(5), Some(30.0)); + + sheet.cells.insert( + CellAddress::parse("A1")?, + SpreadsheetCell { + value: None, + formula: None, + style_index: 0, + citations: Vec::new(), + }, + ); + let merged = CellRange::parse("B2:C3")?; + sheet.merged_ranges = vec![merged.clone(), merged.clone()]; + sheet.cleanup_and_validate_sheet()?; + + assert_eq!(sheet.cells, BTreeMap::new()); + assert_eq!(sheet.merged_ranges, vec![merged]); + Ok(()) +} + +#[test] +fn xlsx_roundtrip_preserves_row_and_column_sizes() -> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let path = temp_dir.path().join("sizing.xlsx"); + + let mut artifact = SpreadsheetArtifact::new(Some("Sizing".to_string())); + let (expected_column_widths, expected_row_heights, expected_show_grid_lines) = { + let sheet = artifact.create_sheet("Sheet1".to_string())?; + sheet.show_grid_lines = false; + sheet.set_value( + CellAddress::parse("A1")?, + Some(SpreadsheetCellValue::Integer(42)), + )?; + sheet.set_column_widths_bulk(&BTreeMap::from([ + ("A:B".to_string(), 12.5), + ("D".to_string(), 18.0), + ]))?; + sheet.set_row_heights_bulk(&BTreeMap::from([(2, Some(24.0)), (6, Some(19.5))]))?; + ( + sheet.column_widths.clone(), + sheet.row_heights.clone(), + sheet.show_grid_lines, + ) + }; + artifact.export(&path)?; + + let restored = SpreadsheetArtifact::from_source_file(&path, None)?; + let restored_sheet = restored.get_sheet(Some("Sheet1"), None).expect("sheet"); + assert_eq!(restored_sheet.column_widths, expected_column_widths); + assert_eq!(restored_sheet.row_heights, expected_row_heights); + assert_eq!(restored_sheet.show_grid_lines, expected_show_grid_lines); + Ok(()) +} + +#[test] +fn manager_supports_bulk_sizes_and_row_heights() -> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = SpreadsheetArtifactManager::default(); + let created = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Sizing" }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id; + + manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "create_sheet".to_string(), + args: serde_json::json!({ "name": "Sheet1" }), + }, + temp_dir.path(), + )?; + + manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "set_column_widths_bulk".to_string(), + args: serde_json::json!({ + "sheet_name": "Sheet1", + "widths": { + "A:B": 12.0, + "D": 20.0 + } + }), + }, + temp_dir.path(), + )?; + manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "set_row_height".to_string(), + args: serde_json::json!({ + "sheet_name": "Sheet1", + "row_index": 2, + "height": 18.0 + }), + }, + temp_dir.path(), + )?; + manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "set_row_heights".to_string(), + args: serde_json::json!({ + "sheet_name": "Sheet1", + "start_row_index": 3, + "end_row_index": 4, + "height": 21.0 + }), + }, + temp_dir.path(), + )?; + manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "set_row_heights_bulk".to_string(), + args: serde_json::json!({ + "sheet_name": "Sheet1", + "heights": { + "4": null, + "5": 25.0 + } + }), + }, + temp_dir.path(), + )?; + + let row_height = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "get_row_height".to_string(), + args: serde_json::json!({ + "sheet_name": "Sheet1", + "row_index": 5 + }), + }, + temp_dir.path(), + )?; + assert_eq!(row_height.row_height, Some(25.0)); + + manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "cleanup_and_validate_sheet".to_string(), + args: serde_json::json!({ + "sheet_name": "Sheet1" + }), + }, + temp_dir.path(), + )?; + + let sheet = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id), + action: "get_sheet".to_string(), + args: serde_json::json!({ + "sheet_name": "Sheet1" + }), + }, + temp_dir.path(), + )?; + let restored: SpreadsheetSheet = + serde_json::from_value(sheet.serialized_dict.expect("sheet dict"))?; + assert_eq!( + restored.column_widths, + BTreeMap::from([(1, 12.0), (2, 12.0), (4, 20.0)]) + ); + assert_eq!( + restored.row_heights, + BTreeMap::from([(2, 18.0), (3, 21.0), (5, 25.0)]) + ); + Ok(()) +} + +#[test] +fn manager_style_registry_and_format_summaries_work() -> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = SpreadsheetArtifactManager::default(); + let created = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Styles" }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id; + + manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "create_sheet".to_string(), + args: serde_json::json!({ "name": "Sheet1" }), + }, + temp_dir.path(), + )?; + + let text_style = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "create_text_style".to_string(), + args: serde_json::json!({ + "style": { + "bold": true, + "italic": true, + "underline": true, + "font_size": 14.0, + "font_color": "#112233", + "text_alignment": "center", + "anchor": "middle", + "vertical_text_orientation": "stacked", + "text_rotation": 90, + "paragraph_spacing": true, + "bottom_inset": 1.0, + "left_inset": 2.0, + "right_inset": 3.0, + "top_inset": 4.0, + "font_family": "IBM Plex Sans", + "font_scheme": "minor", + "typeface": "IBM Plex Sans", + "font_face": { + "font_family": "IBM Plex Sans", + "font_scheme": "minor", + "typeface": "IBM Plex Sans" + } + } + }), + }, + temp_dir.path(), + )?; + let text_style_id = text_style.style_id.expect("text style id"); + + let fill = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "create_fill".to_string(), + args: serde_json::json!({ + "fill": { + "solid_fill_color": "#ffeeaa", + "pattern_type": "solid", + "pattern_foreground_color": "#ffeeaa", + "pattern_background_color": "#221100", + "color_transforms": ["tint:0.2"], + "gradient_fill_type": "linear", + "gradient_stops": [ + { "position": 0.0, "color": "#ffeeaa" }, + { "position": 1.0, "color": "#aa5500" } + ], + "gradient_kind": "linear", + "angle": 45.0, + "scaled": true, + "path_type": "rect", + "fill_rectangle": { + "left": 0.0, + "right": 1.0, + "top": 0.0, + "bottom": 1.0 + }, + "image_reference": "image://fill" + } + }), + }, + temp_dir.path(), + )?; + let fill_id = fill.style_id.expect("fill id"); + + let border = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "create_border".to_string(), + args: serde_json::json!({ + "border": { + "top": { "style": "solid", "color": "#111111" }, + "right": { "style": "dashed", "color": "#222222" }, + "bottom": { "style": "double", "color": "#333333" }, + "left": { "style": "solid", "color": "#444444" } + } + }), + }, + temp_dir.path(), + )?; + let border_id = border.style_id.expect("border id"); + + let number_format = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "create_number_format".to_string(), + args: serde_json::json!({ + "number_format": { + "format_id": 4 + } + }), + }, + temp_dir.path(), + )?; + let number_format_id = number_format.style_id.expect("number format id"); + + let base_format = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "create_cell_format".to_string(), + args: serde_json::json!({ + "format": { + "text_style_id": text_style_id, + "number_format_id": number_format_id, + "alignment": { + "horizontal": "center", + "vertical": "middle" + } + } + }), + }, + temp_dir.path(), + )?; + let base_format_id = base_format.style_id.expect("base format id"); + + let derived_format = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "create_cell_format".to_string(), + args: serde_json::json!({ + "format": { + "fill_id": fill_id, + "border_id": border_id, + "wrap_text": true, + "base_cell_style_format_id": base_format_id + } + }), + }, + temp_dir.path(), + )?; + let derived_format_id = derived_format.style_id.expect("derived format id"); + + let merged_format = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "create_cell_format".to_string(), + args: serde_json::json!({ + "source_format_id": derived_format_id, + "merge_with_existing_components": true, + "format": { + "alignment": { + "vertical": "bottom" + } + } + }), + }, + temp_dir.path(), + )?; + let merged_format_id = merged_format.style_id.expect("merged format id"); + + let differential_format = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "create_differential_format".to_string(), + args: serde_json::json!({ + "format": { + "fill_id": fill_id, + "wrap_text": true + } + }), + }, + temp_dir.path(), + )?; + let differential_format_id = differential_format.style_id.expect("dxf id"); + + manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "set_cell_style".to_string(), + args: serde_json::json!({ + "sheet_name": "Sheet1", + "address": "A1", + "style_index": merged_format_id + }), + }, + temp_dir.path(), + )?; + + let summary = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "get_cell_format_summary".to_string(), + args: serde_json::json!({ + "sheet_name": "Sheet1", + "address": "A1" + }), + }, + temp_dir.path(), + )?; + assert_eq!( + summary.cell_format_summary, + Some(SpreadsheetCellFormatSummary { + style_index: merged_format_id, + text_style: Some(SpreadsheetTextStyle { + bold: Some(true), + italic: Some(true), + underline: Some(true), + font_size: Some(14.0), + font_color: Some("#112233".to_string()), + text_alignment: Some("center".to_string()), + anchor: Some("middle".to_string()), + vertical_text_orientation: Some("stacked".to_string()), + text_rotation: Some(90), + paragraph_spacing: Some(true), + bottom_inset: Some(1.0), + left_inset: Some(2.0), + right_inset: Some(3.0), + top_inset: Some(4.0), + font_family: Some("IBM Plex Sans".to_string()), + font_scheme: Some("minor".to_string()), + typeface: Some("IBM Plex Sans".to_string()), + font_face: Some(SpreadsheetFontFace { + font_family: Some("IBM Plex Sans".to_string()), + font_scheme: Some("minor".to_string()), + typeface: Some("IBM Plex Sans".to_string()), + }), + }), + fill: Some(SpreadsheetFill { + solid_fill_color: Some("#ffeeaa".to_string()), + pattern_type: Some("solid".to_string()), + pattern_foreground_color: Some("#ffeeaa".to_string()), + pattern_background_color: Some("#221100".to_string()), + color_transforms: vec!["tint:0.2".to_string()], + gradient_fill_type: Some("linear".to_string()), + gradient_stops: vec![ + crate::SpreadsheetGradientStop { + position: 0.0, + color: "#ffeeaa".to_string(), + }, + crate::SpreadsheetGradientStop { + position: 1.0, + color: "#aa5500".to_string(), + }, + ], + gradient_kind: Some("linear".to_string()), + angle: Some(45.0), + scaled: Some(true), + path_type: Some("rect".to_string()), + fill_rectangle: Some(crate::SpreadsheetFillRectangle { + left: 0.0, + right: 1.0, + top: 0.0, + bottom: 1.0, + }), + image_reference: Some("image://fill".to_string()), + }), + border: Some(crate::SpreadsheetBorder { + top: Some(crate::SpreadsheetBorderLine { + style: Some("solid".to_string()), + color: Some("#111111".to_string()), + }), + right: Some(crate::SpreadsheetBorderLine { + style: Some("dashed".to_string()), + color: Some("#222222".to_string()), + }), + bottom: Some(crate::SpreadsheetBorderLine { + style: Some("double".to_string()), + color: Some("#333333".to_string()), + }), + left: Some(crate::SpreadsheetBorderLine { + style: Some("solid".to_string()), + color: Some("#444444".to_string()), + }), + }), + alignment: Some(crate::SpreadsheetAlignment { + horizontal: Some("center".to_string()), + vertical: Some("bottom".to_string()), + }), + number_format: Some(SpreadsheetNumberFormat { + format_id: Some(4), + format_code: Some("#,##0.00".to_string()), + }), + wrap_text: Some(true), + }) + ); + + let retrieved_format = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "get_cell_format".to_string(), + args: serde_json::json!({ "id": merged_format_id }), + }, + temp_dir.path(), + )?; + let retrieved_format: SpreadsheetCellFormat = + serde_json::from_value(retrieved_format.serialized_dict.expect("cell format"))?; + assert_eq!( + retrieved_format, + SpreadsheetCellFormat { + text_style_id: None, + fill_id: Some(fill_id), + border_id: Some(border_id), + alignment: Some(crate::SpreadsheetAlignment { + horizontal: None, + vertical: Some("bottom".to_string()), + }), + number_format_id: None, + wrap_text: Some(true), + base_cell_style_format_id: Some(base_format_id), + } + ); + + let retrieved_number_format = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "get_number_format".to_string(), + args: serde_json::json!({ "id": number_format_id }), + }, + temp_dir.path(), + )?; + assert_eq!( + serde_json::from_value::( + retrieved_number_format + .serialized_dict + .expect("number format") + )?, + SpreadsheetNumberFormat { + format_id: Some(4), + format_code: Some("#,##0.00".to_string()), + } + ); + + let retrieved_text_style = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "get_text_style".to_string(), + args: serde_json::json!({ "id": text_style_id }), + }, + temp_dir.path(), + )?; + assert_eq!( + serde_json::from_value::( + retrieved_text_style.serialized_dict.expect("text style") + )? + .bold, + Some(true) + ); + + let range_summary = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "get_range_format_summary".to_string(), + args: serde_json::json!({ + "sheet_name": "Sheet1", + "range": "A1:B2" + }), + }, + temp_dir.path(), + )?; + assert_eq!(range_summary.top_left_style_index, Some(merged_format_id)); + assert_eq!( + range_summary + .range_format + .as_ref() + .map(|format| format.range.clone()), + Some("A1:B2".to_string()) + ); + + let retrieved_dxf = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id), + action: "get_differential_format".to_string(), + args: serde_json::json!({ "id": differential_format_id }), + }, + temp_dir.path(), + )?; + assert_eq!( + serde_json::from_value::( + retrieved_dxf.serialized_dict.expect("differential format") + )? + .wrap_text, + Some(true) + ); + Ok(()) +} + +#[test] +fn sheet_references_resolve_cells_and_ranges() -> Result<(), Box> { + let sheet = SpreadsheetSheet::new("Sheet1".to_string()); + assert_eq!( + sheet.reference("A1")?, + SpreadsheetSheetReference::Cell { + cell_ref: sheet.cell_ref("A1")?, + } + ); + assert_eq!( + sheet.reference("A1:B2")?, + SpreadsheetSheetReference::Range { + range_ref: sheet.range_ref("A1:B2")?, + } + ); + Ok(()) +} + +#[test] +fn manager_get_reference_and_xlsx_import_preserve_workbook_name() +-> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let path = temp_dir.path().join("named.xlsx"); + + let mut artifact = SpreadsheetArtifact::new(Some("Named Workbook".to_string())); + artifact.create_sheet("Sheet1".to_string())?.set_value( + CellAddress::parse("A1")?, + Some(SpreadsheetCellValue::Integer(9)), + )?; + artifact.export(&path)?; + + let restored = SpreadsheetArtifact::from_source_file(&path, None)?; + assert_eq!(restored.name, Some("Named Workbook".to_string())); + + let mut manager = SpreadsheetArtifactManager::default(); + let imported = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: None, + action: "read".to_string(), + args: serde_json::json!({ "path": path }), + }, + temp_dir.path(), + )?; + let artifact_id = imported.artifact_id; + + let cell_reference = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "get_reference".to_string(), + args: serde_json::json!({ + "sheet_name": "Sheet1", + "reference": "A1" + }), + }, + temp_dir.path(), + )?; + assert_eq!( + cell_reference + .raw_cell + .as_ref() + .and_then(|cell| cell.value.clone()), + Some(SpreadsheetCellValue::Integer(9)) + ); + + let range_reference = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id), + action: "get_reference".to_string(), + args: serde_json::json!({ + "sheet_name": "Sheet1", + "reference": "A1:B2" + }), + }, + temp_dir.path(), + )?; + assert_eq!( + range_reference + .range_ref + .as_ref() + .map(|range_ref| range_ref.address.clone()), + Some("A1:B2".to_string()) + ); + Ok(()) +} + +#[test] +fn manager_render_actions_support_workbook_sheet_and_range() +-> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = SpreadsheetArtifactManager::default(); + let created = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Render" }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id; + + manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "create_sheet".to_string(), + args: serde_json::json!({ "name": "Sheet1" }), + }, + temp_dir.path(), + )?; + manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "set_range_values".to_string(), + args: serde_json::json!({ + "sheet_name": "Sheet1", + "range": "A1:C4", + "values": [ + ["h1", "h2", "h3"], + ["a", 1, 2], + ["b", 3, 4], + ["c", 5, 6] + ] + }), + }, + temp_dir.path(), + )?; + + let workbook = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "render_workbook".to_string(), + args: serde_json::json!({ + "output_path": temp_dir.path().join("workbook-previews"), + "include_headers": false + }), + }, + temp_dir.path(), + )?; + assert_eq!(workbook.exported_paths.len(), 1); + assert!(workbook.exported_paths[0].exists()); + + let sheet = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "render_sheet".to_string(), + args: serde_json::json!({ + "sheet_name": "Sheet1", + "output_path": temp_dir.path().join("sheet-preview.html"), + "center_address": "B3", + "width": 220, + "height": 90, + "scale": 1.5, + "performance_mode": true + }), + }, + temp_dir.path(), + )?; + assert_eq!(sheet.exported_paths.len(), 1); + assert!(sheet.exported_paths[0].exists()); + assert!( + sheet + .rendered_html + .as_ref() + .is_some_and(|html| html.contains("data-performance-mode=\"true\"")) + ); + + let range = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id), + action: "render_range".to_string(), + args: serde_json::json!({ + "sheet_name": "Sheet1", + "range": "A2:C4", + "output_path": temp_dir.path().join("range-preview.html"), + "include_headers": true + }), + }, + temp_dir.path(), + )?; + assert_eq!(range.exported_paths.len(), 1); + assert_eq!( + range + .range_ref + .as_ref() + .map(|range_ref| range_ref.address.clone()), + Some("A2:C4".to_string()) + ); + assert!( + range + .rendered_html + .as_ref() + .is_some_and(|html| html.contains("A")) + ); + Ok(()) +} diff --git a/codex-rs/artifact-spreadsheet/src/xlsx.rs b/codex-rs/artifact-spreadsheet/src/xlsx.rs new file mode 100644 index 0000000000..5175f3b51e --- /dev/null +++ b/codex-rs/artifact-spreadsheet/src/xlsx.rs @@ -0,0 +1,860 @@ +use std::collections::BTreeMap; +use std::fs::File; +use std::io::Read; +use std::io::Write; +use std::path::Path; +use std::path::PathBuf; + +use regex::Regex; +use zip::ZipArchive; +use zip::ZipWriter; +use zip::write::SimpleFileOptions; + +use crate::CellAddress; +use crate::CellRange; +use crate::SpreadsheetArtifact; +use crate::SpreadsheetArtifactError; +use crate::SpreadsheetCell; +use crate::SpreadsheetCellValue; +use crate::SpreadsheetSheet; + +pub(crate) fn write_xlsx( + artifact: &mut SpreadsheetArtifact, + path: &Path, +) -> Result { + if artifact.auto_recalculate { + artifact.recalculate(); + } + for sheet in &mut artifact.sheets { + sheet.cleanup_and_validate_sheet()?; + } + + let file = File::create(path).map_err(|error| SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + let mut zip = ZipWriter::new(file); + let options = SimpleFileOptions::default(); + + let sheet_count = artifact.sheets.len().max(1); + zip.start_file("[Content_Types].xml", options) + .map_err(|error| SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + zip.write_all(content_types_xml(sheet_count).as_bytes()) + .map_err(|error| SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + + zip.add_directory("_rels/", options).map_err(|error| { + SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + } + })?; + zip.start_file("_rels/.rels", options).map_err(|error| { + SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + } + })?; + zip.write_all(root_relationships_xml().as_bytes()) + .map_err(|error| SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + + zip.add_directory("docProps/", options).map_err(|error| { + SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + } + })?; + zip.start_file("docProps/app.xml", options) + .map_err(|error| SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + zip.write_all(app_xml(artifact).as_bytes()) + .map_err(|error| SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + + zip.start_file("docProps/core.xml", options) + .map_err(|error| SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + zip.write_all(core_xml(artifact).as_bytes()) + .map_err(|error| SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + + zip.add_directory("xl/", options) + .map_err(|error| SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + zip.start_file("xl/workbook.xml", options) + .map_err(|error| SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + zip.write_all(workbook_xml(artifact).as_bytes()) + .map_err(|error| SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + + zip.add_directory("xl/_rels/", options).map_err(|error| { + SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + } + })?; + zip.start_file("xl/_rels/workbook.xml.rels", options) + .map_err(|error| SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + zip.write_all(workbook_relationships_xml(artifact).as_bytes()) + .map_err(|error| SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + + zip.start_file("xl/styles.xml", options).map_err(|error| { + SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + } + })?; + zip.write_all(styles_xml(artifact).as_bytes()) + .map_err(|error| SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + + zip.add_directory("xl/worksheets/", options) + .map_err(|error| SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + if artifact.sheets.is_empty() { + let empty = SpreadsheetSheet::new("Sheet1".to_string()); + zip.start_file("xl/worksheets/sheet1.xml", options) + .map_err(|error| SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + zip.write_all(sheet_xml(&empty).as_bytes()) + .map_err(|error| SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + } else { + for (index, sheet) in artifact.sheets.iter().enumerate() { + zip.start_file(format!("xl/worksheets/sheet{}.xml", index + 1), options) + .map_err(|error| SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + zip.write_all(sheet_xml(sheet).as_bytes()) + .map_err(|error| SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + } + } + + zip.finish() + .map_err(|error| SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + Ok(path.to_path_buf()) +} + +pub(crate) fn import_xlsx( + path: &Path, + artifact_id: Option, +) -> Result { + let file = File::open(path).map_err(|error| SpreadsheetArtifactError::ImportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + let mut archive = + ZipArchive::new(file).map_err(|error| SpreadsheetArtifactError::ImportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + + let workbook_xml = read_zip_entry(&mut archive, "xl/workbook.xml", path)?; + let workbook_rels = read_zip_entry(&mut archive, "xl/_rels/workbook.xml.rels", path)?; + let workbook_name = if archive.by_name("docProps/core.xml").is_ok() { + let title = + extract_workbook_title(&read_zip_entry(&mut archive, "docProps/core.xml", path)?); + (!title.trim().is_empty()).then_some(title) + } else { + None + }; + let shared_strings = if archive.by_name("xl/sharedStrings.xml").is_ok() { + Some(parse_shared_strings(&read_zip_entry( + &mut archive, + "xl/sharedStrings.xml", + path, + )?)?) + } else { + None + }; + + let relationships = parse_relationships(&workbook_rels)?; + let sheets = parse_sheet_definitions(&workbook_xml)? + .into_iter() + .map(|(name, relation)| { + let target = relationships.get(&relation).ok_or_else(|| { + SpreadsheetArtifactError::ImportFailed { + path: path.to_path_buf(), + message: format!("missing relationship `{relation}` for sheet `{name}`"), + } + })?; + let normalized = if target.starts_with('/') { + target.trim_start_matches('/').to_string() + } else if target.starts_with("xl/") { + target.clone() + } else { + format!("xl/{target}") + }; + Ok((name, normalized)) + }) + .collect::, SpreadsheetArtifactError>>()?; + + let mut artifact = SpreadsheetArtifact::new(workbook_name.or_else(|| { + path.file_stem() + .and_then(|value| value.to_str()) + .map(str::to_string) + })); + if let Some(artifact_id) = artifact_id { + artifact.artifact_id = artifact_id; + } + artifact.sheets.clear(); + + for (name, target) in sheets { + let xml = read_zip_entry(&mut archive, &target, path)?; + let sheet = parse_sheet(&name, &xml, shared_strings.as_deref())?; + artifact.sheets.push(sheet); + } + + Ok(artifact) +} + +fn read_zip_entry( + archive: &mut ZipArchive, + entry: &str, + path: &Path, +) -> Result { + let mut file = + archive + .by_name(entry) + .map_err(|error| SpreadsheetArtifactError::ImportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + let mut text = String::new(); + file.read_to_string(&mut text) + .map_err(|error| SpreadsheetArtifactError::ImportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + Ok(text) +} + +fn parse_sheet_definitions( + workbook_xml: &str, +) -> Result, SpreadsheetArtifactError> { + let regex = Regex::new(r#"]*)/?>"#).map_err(|error| { + SpreadsheetArtifactError::Serialization { + message: error.to_string(), + } + })?; + let mut sheets = Vec::new(); + for captures in regex.captures_iter(workbook_xml) { + let Some(attributes) = captures.get(1).map(|value| value.as_str()) else { + continue; + }; + let Some(name) = extract_attribute(attributes, "name") else { + continue; + }; + let relation = extract_attribute(attributes, "r:id") + .or_else(|| extract_attribute(attributes, "id")) + .unwrap_or_default(); + sheets.push((xml_unescape(&name), relation)); + } + Ok(sheets) +} + +fn parse_relationships(xml: &str) -> Result, SpreadsheetArtifactError> { + let regex = Regex::new(r#"]*)/?>"#).map_err(|error| { + SpreadsheetArtifactError::Serialization { + message: error.to_string(), + } + })?; + Ok(regex + .captures_iter(xml) + .filter_map(|captures| { + let attributes = captures.get(1)?.as_str(); + let id = extract_attribute(attributes, "Id")?; + let target = extract_attribute(attributes, "Target")?; + Some((id, target)) + }) + .collect()) +} + +fn parse_shared_strings(xml: &str) -> Result, SpreadsheetArtifactError> { + let regex = Regex::new(r#"(?s)]*>(.*?)"#).map_err(|error| { + SpreadsheetArtifactError::Serialization { + message: error.to_string(), + } + })?; + regex + .captures_iter(xml) + .filter_map(|captures| captures.get(1).map(|value| value.as_str())) + .map(all_text_nodes) + .collect() +} + +fn parse_sheet( + name: &str, + xml: &str, + shared_strings: Option<&[String]>, +) -> Result { + let mut sheet = SpreadsheetSheet::new(name.to_string()); + + if let Some(sheet_view) = first_tag_attributes(xml, "sheetView") + && let Some(show_grid_lines) = extract_attribute(&sheet_view, "showGridLines") + { + sheet.show_grid_lines = show_grid_lines != "0"; + } + if let Some(format_pr) = first_tag_attributes(xml, "sheetFormatPr") { + sheet.default_row_height = extract_attribute(&format_pr, "defaultRowHeight") + .and_then(|value| value.parse::().ok()); + sheet.default_column_width = extract_attribute(&format_pr, "defaultColWidth") + .and_then(|value| value.parse::().ok()); + } + + let col_regex = Regex::new(r#"]*)/?>"#).map_err(|error| { + SpreadsheetArtifactError::Serialization { + message: error.to_string(), + } + })?; + for captures in col_regex.captures_iter(xml) { + let Some(attributes) = captures.get(1).map(|value| value.as_str()) else { + continue; + }; + let Some(min) = + extract_attribute(attributes, "min").and_then(|value| value.parse::().ok()) + else { + continue; + }; + let Some(max) = + extract_attribute(attributes, "max").and_then(|value| value.parse::().ok()) + else { + continue; + }; + let Some(width) = + extract_attribute(attributes, "width").and_then(|value| value.parse::().ok()) + else { + continue; + }; + for column in min..=max { + sheet.column_widths.insert(column, width); + } + } + + let row_regex = Regex::new(r#"(?s)]*)>(.*?)"#).map_err(|error| { + SpreadsheetArtifactError::Serialization { + message: error.to_string(), + } + })?; + let cell_regex = Regex::new(r#"(?s)]*)>(.*?)"#).map_err(|error| { + SpreadsheetArtifactError::Serialization { + message: error.to_string(), + } + })?; + for row_captures in row_regex.captures_iter(xml) { + let row_attributes = row_captures + .get(1) + .map(|value| value.as_str()) + .unwrap_or_default(); + if let Some(row_index) = + extract_attribute(row_attributes, "r").and_then(|value| value.parse::().ok()) + && let Some(height) = + extract_attribute(row_attributes, "ht").and_then(|value| value.parse::().ok()) + && row_index > 0 + && height > 0.0 + { + sheet.row_heights.insert(row_index, height); + } + let Some(row_body) = row_captures.get(2).map(|value| value.as_str()) else { + continue; + }; + for cell_captures in cell_regex.captures_iter(row_body) { + let Some(attributes) = cell_captures.get(1).map(|value| value.as_str()) else { + continue; + }; + let Some(body) = cell_captures.get(2).map(|value| value.as_str()) else { + continue; + }; + let Some(address) = extract_attribute(attributes, "r") else { + continue; + }; + let address = CellAddress::parse(&address)?; + let style_index = extract_attribute(attributes, "s") + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + let cell_type = extract_attribute(attributes, "t").unwrap_or_default(); + let formula = first_tag_text(body, "f").map(|value| format!("={value}")); + let value = parse_cell_value(body, &cell_type, shared_strings)?; + + let cell = SpreadsheetCell { + value, + formula, + style_index, + citations: Vec::new(), + }; + if !cell.is_empty() { + sheet.cells.insert(address, cell); + } + } + } + + let merge_regex = Regex::new(r#"]*)/?>"#).map_err(|error| { + SpreadsheetArtifactError::Serialization { + message: error.to_string(), + } + })?; + for captures in merge_regex.captures_iter(xml) { + let Some(attributes) = captures.get(1).map(|value| value.as_str()) else { + continue; + }; + if let Some(reference) = extract_attribute(attributes, "ref") { + sheet.merged_ranges.push(CellRange::parse(&reference)?); + } + } + + Ok(sheet) +} + +fn parse_cell_value( + body: &str, + cell_type: &str, + shared_strings: Option<&[String]>, +) -> Result, SpreadsheetArtifactError> { + let inline_text = first_tag_text(body, "t").map(|value| xml_unescape(&value)); + let raw_value = first_tag_text(body, "v").map(|value| xml_unescape(&value)); + + let parsed = match cell_type { + "inlineStr" => inline_text.map(SpreadsheetCellValue::String), + "s" => raw_value + .and_then(|value| value.parse::().ok()) + .and_then(|index| shared_strings.and_then(|entries| entries.get(index).cloned())) + .map(SpreadsheetCellValue::String), + "b" => raw_value.map(|value| SpreadsheetCellValue::Bool(value == "1")), + "str" => raw_value.map(SpreadsheetCellValue::String), + "e" => raw_value.map(SpreadsheetCellValue::Error), + _ => match raw_value { + Some(value) => { + if let Ok(integer) = value.parse::() { + Some(SpreadsheetCellValue::Integer(integer)) + } else if let Ok(float) = value.parse::() { + Some(SpreadsheetCellValue::Float(float)) + } else { + Some(SpreadsheetCellValue::String(value)) + } + } + None => None, + }, + }; + Ok(parsed) +} + +fn content_types_xml(sheet_count: usize) -> String { + let mut overrides = String::new(); + for index in 1..=sheet_count { + overrides.push_str(&format!( + r#""# + )); + } + format!( + "{}{}{}{}{}{}{}{}{}{}", + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + overrides, + r#""# + ) +} + +fn root_relationships_xml() -> &'static str { + concat!( + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""# + ) +} + +fn app_xml(artifact: &SpreadsheetArtifact) -> String { + let title = artifact + .name + .clone() + .unwrap_or_else(|| "Spreadsheet".to_string()); + format!( + concat!( + r#""#, + r#""#, + r#"Codex"#, + r#"0"#, + r#"false"#, + r#"Worksheets{}"#, + r#"{}"#, + r#"OpenAI"#, + r#"{}"#, + r#""# + ), + artifact.sheets.len(), + artifact.sheets.len(), + artifact + .sheets + .iter() + .map(|sheet| format!(r#"{}"#, xml_escape(&sheet.name))) + .collect::>() + .join(""), + xml_escape(&title), + ) +} + +fn core_xml(artifact: &SpreadsheetArtifact) -> String { + let title = artifact + .name + .clone() + .unwrap_or_else(|| artifact.artifact_id.clone()); + format!( + concat!( + r#""#, + r#""#, + r#"{}"#, + r#"Codex"#, + r#"Codex"#, + r#""# + ), + xml_escape(&title), + ) +} + +fn workbook_xml(artifact: &SpreadsheetArtifact) -> String { + let sheets = if artifact.sheets.is_empty() { + r#""#.to_string() + } else { + artifact + .sheets + .iter() + .enumerate() + .map(|(index, sheet)| { + format!( + r#""#, + xml_escape(&sheet.name), + index + 1, + index + 1 + ) + }) + .collect::>() + .join("") + }; + format!( + "{}{}{}{}{}", + r#""#, + r#""#, + r#""#, + sheets, + r#""# + ) +} + +fn workbook_relationships_xml(artifact: &SpreadsheetArtifact) -> String { + let sheet_relationships = if artifact.sheets.is_empty() { + r#""#.to_string() + } else { + artifact + .sheets + .iter() + .enumerate() + .map(|(index, _)| { + format!( + r#""#, + index + 1, + index + 1 + ) + }) + .collect::>() + .join("") + }; + let style_relation_id = artifact.sheets.len().max(1) + 1; + format!( + "{}{}{}{}", + r#""#, + r#""#, + sheet_relationships, + style_relation_id, + r#""# + ) +} + +fn styles_xml(artifact: &SpreadsheetArtifact) -> String { + let max_style_index = artifact + .sheets + .iter() + .flat_map(|sheet| sheet.cells.values().map(|cell| cell.style_index)) + .max() + .unwrap_or(0); + let cell_xfs = (0..=max_style_index) + .map(|_| r#""#) + .collect::>() + .join(""); + format!( + concat!( + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#"{}"#, + r#""#, + r#""# + ), + max_style_index + 1, + cell_xfs, + ) +} + +fn sheet_xml(sheet: &SpreadsheetSheet) -> String { + let mut rows = BTreeMap::>::new(); + for row_index in sheet.row_heights.keys() { + rows.entry(*row_index).or_default(); + } + for (address, cell) in &sheet.cells { + rows.entry(address.row).or_default().push((*address, cell)); + } + + let sheet_data = rows + .into_iter() + .map(|(row_index, mut entries)| { + entries.sort_by_key(|(address, _)| address.column); + let cells = entries + .into_iter() + .map(|(address, cell)| cell_xml(address, cell)) + .collect::>() + .join(""); + let height = sheet + .row_heights + .get(&row_index) + .map(|value| format!(r#" ht="{value}" customHeight="1""#)) + .unwrap_or_default(); + format!(r#"{cells}"#) + }) + .collect::>() + .join(""); + + let cols = if sheet.column_widths.is_empty() { + String::new() + } else { + let mut groups = Vec::new(); + let mut iter = sheet.column_widths.iter().peekable(); + while let Some((&start, &width)) = iter.next() { + let mut end = start; + while let Some((next_column, next_width)) = + iter.peek().map(|(column, width)| (**column, **width)) + { + if next_column == end + 1 && (next_width - width).abs() < f64::EPSILON { + end = next_column; + iter.next(); + } else { + break; + } + } + groups.push(format!( + r#""# + )); + } + format!("{}", groups.join("")) + }; + + let merge_cells = if sheet.merged_ranges.is_empty() { + String::new() + } else { + format!( + r#"{}"#, + sheet.merged_ranges.len(), + sheet + .merged_ranges + .iter() + .map(|range| format!(r#""#, range.to_a1())) + .collect::>() + .join("") + ) + }; + + let default_row_height = sheet.default_row_height.unwrap_or(15.0); + let default_column_width = sheet.default_column_width.unwrap_or(8.43); + let grid_lines = if sheet.show_grid_lines { "1" } else { "0" }; + + format!( + "{}{}{}{}{}{}", + r#""#, + r#""#, + grid_lines, + default_row_height, + default_column_width, + cols, + sheet_data, + merge_cells, + r#""# + ) +} + +fn cell_xml(address: CellAddress, cell: &SpreadsheetCell) -> String { + let style = if cell.style_index == 0 { + String::new() + } else { + format!(r#" s="{}""#, cell.style_index) + }; + + if let Some(formula) = &cell.formula { + let formula = xml_escape(formula.trim_start_matches('=')); + let value_xml = match &cell.value { + Some(SpreadsheetCellValue::Bool(value)) => { + format!( + r#" t="b">{formula}{}"#, + usize::from(*value) + ) + } + Some(SpreadsheetCellValue::Integer(value)) => { + format!(r#">{formula}{value}"#) + } + Some(SpreadsheetCellValue::Float(value)) => { + format!(r#">{formula}{value}"#) + } + Some(SpreadsheetCellValue::String(value)) + | Some(SpreadsheetCellValue::DateTime(value)) => format!( + r#" t="str">{formula}{}"#, + xml_escape(value) + ), + Some(SpreadsheetCellValue::Error(value)) => { + format!(r#" t="e">{formula}{}"#, xml_escape(value)) + } + None => format!(r#">{formula}"#), + }; + return format!(r#" format!( + r#"{}"#, + address.to_a1(), + usize::from(*value) + ), + Some(SpreadsheetCellValue::Integer(value)) => { + format!(r#"{value}"#, address.to_a1()) + } + Some(SpreadsheetCellValue::Float(value)) => { + format!(r#"{value}"#, address.to_a1()) + } + Some(SpreadsheetCellValue::String(value)) | Some(SpreadsheetCellValue::DateTime(value)) => { + format!( + r#"{}"#, + address.to_a1(), + xml_escape(value) + ) + } + Some(SpreadsheetCellValue::Error(value)) => format!( + r#"{}"#, + address.to_a1(), + xml_escape(value) + ), + None => format!(r#""#, address.to_a1()), + } +} + +fn first_tag_attributes(xml: &str, tag: &str) -> Option { + let regex = Regex::new(&format!(r#"<{tag}\b([^>]*)/?>"#)).ok()?; + let captures = regex.captures(xml)?; + captures.get(1).map(|value| value.as_str().to_string()) +} + +fn first_tag_text(xml: &str, tag: &str) -> Option { + let regex = Regex::new(&format!(r#"(?s)<{tag}\b[^>]*>(.*?)"#)).ok()?; + let captures = regex.captures(xml)?; + captures.get(1).map(|value| value.as_str().to_string()) +} + +fn extract_workbook_title(xml: &str) -> String { + let Ok(regex) = + Regex::new(r#"(?s)<(?:[A-Za-z0-9_]+:)?title\b[^>]*>(.*?)"#) + else { + return String::new(); + }; + regex + .captures(xml) + .and_then(|captures| captures.get(1).map(|value| xml_unescape(value.as_str()))) + .unwrap_or_default() +} + +fn all_text_nodes(xml: &str) -> Result { + let regex = Regex::new(r#"(?s)]*>(.*?)"#).map_err(|error| { + SpreadsheetArtifactError::Serialization { + message: error.to_string(), + } + })?; + Ok(regex + .captures_iter(xml) + .filter_map(|captures| captures.get(1).map(|value| xml_unescape(value.as_str()))) + .collect::>() + .join("")) +} + +fn extract_attribute(attributes: &str, name: &str) -> Option { + let pattern = format!(r#"{name}="([^"]*)""#); + let regex = Regex::new(&pattern).ok()?; + let captures = regex.captures(attributes)?; + captures.get(1).map(|value| xml_unescape(value.as_str())) +} + +fn xml_escape(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +fn xml_unescape(value: &str) -> String { + value + .replace("'", "'") + .replace(""", "\"") + .replace(">", ">") + .replace("<", "<") + .replace("&", "&") +} diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs index 7176a1586d..31b4dcdb44 100644 --- a/codex-rs/codex-api/src/common.rs +++ b/codex-rs/codex-api/src/common.rs @@ -67,8 +67,6 @@ pub enum ResponseEvent { Completed { response_id: String, token_usage: Option, - /// Whether the client can append more items to a long-running websocket response. - can_append: bool, }, OutputTextDelta(String), ReasoningSummaryDelta { @@ -155,6 +153,8 @@ pub struct ResponsesApiRequest { pub stream: bool, pub include: Vec, #[serde(skip_serializing_if = "Option::is_none")] + pub service_tier: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub prompt_cache_key: Option, #[serde(skip_serializing_if = "Option::is_none")] pub text: Option, @@ -174,6 +174,7 @@ impl From<&ResponsesApiRequest> for ResponseCreateWsRequest { store: request.store, stream: request.stream, include: request.include.clone(), + service_tier: request.service_tier.clone(), prompt_cache_key: request.prompt_cache_key.clone(), text: request.text.clone(), generate: None, @@ -197,6 +198,8 @@ pub struct ResponseCreateWsRequest { pub stream: bool, pub include: Vec, #[serde(skip_serializing_if = "Option::is_none")] + pub service_tier: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub prompt_cache_key: Option, #[serde(skip_serializing_if = "Option::is_none")] pub text: Option, @@ -206,20 +209,12 @@ pub struct ResponseCreateWsRequest { pub client_metadata: Option>, } -#[derive(Debug, Serialize)] -pub struct ResponseAppendWsRequest { - pub input: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub client_metadata: Option>, -} #[derive(Debug, Serialize)] #[serde(tag = "type")] #[allow(clippy::large_enum_variant)] pub enum ResponsesWsRequest { #[serde(rename = "response.create")] ResponseCreate(ResponseCreateWsRequest), - #[serde(rename = "response.append")] - ResponseAppend(ResponseAppendWsRequest), } pub fn create_text_param_for_request( diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs index 97d0b9e320..cb6583ded7 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs @@ -4,7 +4,10 @@ use crate::endpoint::realtime_websocket::protocol::RealtimeAudioFrame; use crate::endpoint::realtime_websocket::protocol::RealtimeEvent; use crate::endpoint::realtime_websocket::protocol::RealtimeOutboundMessage; use crate::endpoint::realtime_websocket::protocol::RealtimeSessionConfig; -use crate::endpoint::realtime_websocket::protocol::SessionCreateSession; +use crate::endpoint::realtime_websocket::protocol::SessionAudio; +use crate::endpoint::realtime_websocket::protocol::SessionAudioFormat; +use crate::endpoint::realtime_websocket::protocol::SessionAudioInput; +use crate::endpoint::realtime_websocket::protocol::SessionAudioOutput; use crate::endpoint::realtime_websocket::protocol::SessionUpdateSession; use crate::endpoint::realtime_websocket::protocol::parse_realtime_event; use crate::error::ApiError; @@ -13,6 +16,8 @@ use codex_utils_rustls_provider::ensure_rustls_crypto_provider; use futures::SinkExt; use futures::StreamExt; use http::HeaderMap; +use http::HeaderValue; +use std::collections::HashMap; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; @@ -205,23 +210,13 @@ impl RealtimeWebsocketConnection { self.writer.send_conversation_item_create(text).await } - pub async fn send_session_update( + pub async fn send_conversation_handoff_append( &self, - backend_prompt: String, - conversation_id: Option, + handoff_id: String, + output_text: String, ) -> Result<(), ApiError> { self.writer - .send_session_update(backend_prompt, conversation_id) - .await - } - - pub async fn send_session_create( - &self, - backend_prompt: String, - conversation_id: Option, - ) -> Result<(), ApiError> { - self.writer - .send_session_create(backend_prompt, conversation_id) + .send_conversation_handoff_append(handoff_id, output_text) .await } @@ -262,13 +257,8 @@ impl RealtimeWebsocketConnection { impl RealtimeWebsocketWriter { pub async fn send_audio_frame(&self, frame: RealtimeAudioFrame) -> Result<(), ApiError> { - self.send_json(RealtimeOutboundMessage::InputAudioDelta { - delta: frame.data, - sample_rate: frame.sample_rate, - num_channels: frame.num_channels, - samples_per_channel: frame.samples_per_channel, - }) - .await + self.send_json(RealtimeOutboundMessage::InputAudioBufferAppend { audio: frame.data }) + .await } pub async fn send_conversation_item_create(&self, text: String) -> Result<(), ApiError> { @@ -285,29 +275,34 @@ impl RealtimeWebsocketWriter { .await } - pub async fn send_session_update( + pub async fn send_conversation_handoff_append( &self, - backend_prompt: String, - conversation_id: Option, + handoff_id: String, + output_text: String, ) -> Result<(), ApiError> { - self.send_json(RealtimeOutboundMessage::SessionUpdate { - session: Some(SessionUpdateSession { - backend_prompt, - conversation_id, - }), + self.send_json(RealtimeOutboundMessage::ConversationHandoffAppend { + handoff_id, + output_text, }) .await } - pub async fn send_session_create( - &self, - backend_prompt: String, - conversation_id: Option, - ) -> Result<(), ApiError> { - self.send_json(RealtimeOutboundMessage::SessionCreate { - session: SessionCreateSession { - backend_prompt, - conversation_id, + pub async fn send_session_update(&self, instructions: String) -> Result<(), ApiError> { + self.send_json(RealtimeOutboundMessage::SessionUpdate { + session: SessionUpdateSession { + kind: "quicksilver".to_string(), + instructions, + audio: SessionAudio { + input: SessionAudioInput { + format: SessionAudioFormat { + kind: "audio/pcm".to_string(), + rate: 24_000, + }, + }, + output: SessionAudioOutput { + voice: "mundo".to_string(), + }, + }, }, }) .await @@ -413,14 +408,21 @@ impl RealtimeWebsocketClient { default_headers: HeaderMap, ) -> Result { ensure_rustls_crypto_provider(); - // Keep provider base_url semantics aligned with HTTP clients; derive the ws endpoint here. - let ws_url = websocket_url_from_api_url(self.provider.base_url.as_str())?; + let ws_url = websocket_url_from_api_url( + self.provider.base_url.as_str(), + self.provider.query_params.as_ref(), + config.model.as_deref(), + )?; let mut request = ws_url .as_str() .into_client_request() .map_err(|err| ApiError::Stream(format!("failed to build websocket request: {err}")))?; - let headers = merge_request_headers(&self.provider.headers, extra_headers, default_headers); + let headers = merge_request_headers( + &self.provider.headers, + with_session_id_header(extra_headers, config.session_id.as_deref())?, + default_headers, + ); request.headers_mut().extend(headers); info!("connecting realtime websocket: {ws_url}"); @@ -439,11 +441,12 @@ impl RealtimeWebsocketClient { let (stream, rx_message) = WsStream::new(stream); let connection = RealtimeWebsocketConnection::new(stream, rx_message); debug!( - conversation_id = config.session_id.as_deref().unwrap_or(""), - "realtime websocket sending session.create" + session_id = config.session_id.as_deref().unwrap_or(""), + "realtime websocket sending session.update" ); connection - .send_session_create(config.prompt, config.session_id) + .writer + .send_session_update(config.instructions) .await?; Ok(connection) } @@ -464,38 +467,99 @@ fn merge_request_headers( headers } +fn with_session_id_header( + mut headers: HeaderMap, + session_id: Option<&str>, +) -> Result { + let Some(session_id) = session_id else { + return Ok(headers); + }; + headers.insert( + "x-session-id", + HeaderValue::from_str(session_id).map_err(|err| { + ApiError::Stream(format!("invalid realtime session id header: {err}")) + })?, + ); + Ok(headers) +} + fn websocket_config() -> WebSocketConfig { WebSocketConfig::default() } -fn websocket_url_from_api_url(api_url: &str) -> Result { +fn websocket_url_from_api_url( + api_url: &str, + query_params: Option<&HashMap>, + model: Option<&str>, +) -> Result { let mut url = Url::parse(api_url) .map_err(|err| ApiError::Stream(format!("failed to parse realtime api_url: {err}")))?; + normalize_realtime_path(&mut url); + match url.scheme() { - "ws" | "wss" => { - if url.path().is_empty() || url.path() == "/" { - url.set_path("/ws"); - } - Ok(url) - } + "ws" | "wss" => {} "http" | "https" => { - if url.path().is_empty() || url.path() == "/" { - url.set_path("/ws"); - } let scheme = if url.scheme() == "http" { "ws" } else { "wss" }; let _ = url.set_scheme(scheme); - Ok(url) } - scheme => Err(ApiError::Stream(format!( - "unsupported realtime api_url scheme: {scheme}" - ))), + scheme => { + return Err(ApiError::Stream(format!( + "unsupported realtime api_url scheme: {scheme}" + ))); + } + } + + { + let mut query = url.query_pairs_mut(); + query.append_pair("intent", "quicksilver"); + if let Some(model) = model { + query.append_pair("model", model); + } + if let Some(query_params) = query_params { + for (key, value) in query_params { + if key == "intent" || (key == "model" && model.is_some()) { + continue; + } + query.append_pair(key, value); + } + } + } + + Ok(url) +} + +fn normalize_realtime_path(url: &mut Url) { + let path = url.path().to_string(); + if path.is_empty() || path == "/" { + url.set_path("/v1/realtime"); + return; + } + + if path.ends_with("/realtime") { + return; + } + + if path.ends_with("/realtime/") { + url.set_path(path.trim_end_matches('/')); + return; + } + + if path.ends_with("/v1") { + url.set_path(&format!("{path}/realtime")); + return; + } + + if path.ends_with("/v1/") { + url.set_path(&format!("{path}realtime")); } } #[cfg(test)] mod tests { use super::*; + use crate::endpoint::realtime_websocket::protocol::RealtimeHandoffMessage; + use crate::endpoint::realtime_websocket::protocol::RealtimeHandoffRequested; use http::HeaderValue; use pretty_assertions::assert_eq; use serde_json::Value; @@ -507,17 +571,18 @@ mod tests { use tokio_tungstenite::tungstenite::Message; #[test] - fn parse_session_created_event() { + fn parse_session_updated_event() { let payload = json!({ - "type": "session.created", - "session": {"id": "sess_123"} + "type": "session.updated", + "session": {"id": "sess_123", "instructions": "backend prompt"} }) .to_string(); assert_eq!( parse_realtime_event(payload.as_str()), - Some(RealtimeEvent::SessionCreated { - session_id: "sess_123".to_string() + Some(RealtimeEvent::SessionUpdated { + session_id: "sess_123".to_string(), + instructions: Some("backend prompt".to_string()), }) ); } @@ -525,10 +590,10 @@ mod tests { #[test] fn parse_audio_delta_event() { let payload = json!({ - "type": "response.output_audio.delta", + "type": "conversation.output_audio.delta", "delta": "AAA=", "sample_rate": 48000, - "num_channels": 1, + "channels": 1, "samples_per_channel": 960 }) .to_string(); @@ -547,17 +612,59 @@ mod tests { fn parse_conversation_item_added_event() { let payload = json!({ "type": "conversation.item.added", - "item": {"type": "spawn_transcript", "seq": 7} + "item": {"type": "message", "seq": 7} }) .to_string(); assert_eq!( parse_realtime_event(payload.as_str()), Some(RealtimeEvent::ConversationItemAdded( - json!({"type": "spawn_transcript", "seq": 7}) + json!({"type": "message", "seq": 7}) )) ); } + #[test] + fn parse_conversation_item_done_event() { + let payload = json!({ + "type": "conversation.item.done", + "item": {"id": "item_123", "type": "message"} + }) + .to_string(); + assert_eq!( + parse_realtime_event(payload.as_str()), + Some(RealtimeEvent::ConversationItemDone { + item_id: "item_123".to_string(), + }) + ); + } + + #[test] + fn parse_handoff_requested_event() { + let payload = json!({ + "type": "conversation.handoff.requested", + "handoff_id": "handoff_123", + "item_id": "item_123", + "input_transcript": "delegate this", + "messages": [ + {"role": "user", "text": "delegate this"} + ] + }) + .to_string(); + + assert_eq!( + parse_realtime_event(payload.as_str()), + Some(RealtimeEvent::HandoffRequested(RealtimeHandoffRequested { + handoff_id: "handoff_123".to_string(), + item_id: "item_123".to_string(), + input_transcript: "delegate this".to_string(), + messages: vec![RealtimeHandoffMessage { + role: "user".to_string(), + text: "delegate this".to_string(), + }], + })) + ); + } + #[test] fn merge_request_headers_matches_http_precedence() { let mut provider_headers = HeaderMap::new(); @@ -593,14 +700,61 @@ mod tests { #[test] fn websocket_url_from_http_base_defaults_to_ws_path() { - let url = websocket_url_from_api_url("http://127.0.0.1:8011").expect("build ws url"); - assert_eq!(url.as_str(), "ws://127.0.0.1:8011/ws"); + let url = + websocket_url_from_api_url("http://127.0.0.1:8011", None, None).expect("build ws url"); + assert_eq!( + url.as_str(), + "ws://127.0.0.1:8011/v1/realtime?intent=quicksilver" + ); } #[test] fn websocket_url_from_ws_base_defaults_to_ws_path() { - let url = websocket_url_from_api_url("wss://example.com").expect("build ws url"); - assert_eq!(url.as_str(), "wss://example.com/ws"); + let url = + websocket_url_from_api_url("wss://example.com", None, Some("realtime-test-model")) + .expect("build ws url"); + assert_eq!( + url.as_str(), + "wss://example.com/v1/realtime?intent=quicksilver&model=realtime-test-model" + ); + } + + #[test] + fn websocket_url_from_v1_base_appends_realtime_path() { + let url = websocket_url_from_api_url("https://api.openai.com/v1", None, Some("snapshot")) + .expect("build ws url"); + assert_eq!( + url.as_str(), + "wss://api.openai.com/v1/realtime?intent=quicksilver&model=snapshot" + ); + } + + #[test] + fn websocket_url_from_nested_v1_base_appends_realtime_path() { + let url = + websocket_url_from_api_url("https://example.com/openai/v1", None, Some("snapshot")) + .expect("build ws url"); + assert_eq!( + url.as_str(), + "wss://example.com/openai/v1/realtime?intent=quicksilver&model=snapshot" + ); + } + + #[test] + fn websocket_url_preserves_existing_realtime_path_and_extra_query_params() { + let url = websocket_url_from_api_url( + "https://example.com/v1/realtime?foo=bar", + Some(&HashMap::from([ + ("trace".to_string(), "1".to_string()), + ("intent".to_string(), "ignored".to_string()), + ])), + Some("snapshot"), + ) + .expect("build ws url"); + assert_eq!( + url.as_str(), + "wss://example.com/v1/realtime?foo=bar&intent=quicksilver&model=snapshot&trace=1" + ); } #[tokio::test] @@ -620,26 +774,38 @@ mod tests { .into_text() .expect("text"); let first_json: Value = serde_json::from_str(&first).expect("json"); - assert_eq!(first_json["type"], "session.create"); + assert_eq!(first_json["type"], "session.update"); assert_eq!( - first_json["session"]["backend_prompt"], + first_json["session"]["type"], + Value::String("quicksilver".to_string()) + ); + assert_eq!( + first_json["session"]["instructions"], Value::String("backend prompt".to_string()) ); assert_eq!( - first_json["session"]["conversation_id"], - Value::String("conv_1".to_string()) + first_json["session"]["audio"]["input"]["format"]["type"], + Value::String("audio/pcm".to_string()) + ); + assert_eq!( + first_json["session"]["audio"]["input"]["format"]["rate"], + Value::from(24_000) + ); + assert_eq!( + first_json["session"]["audio"]["output"]["voice"], + Value::String("mundo".to_string()) ); ws.send(Message::Text( json!({ - "type": "session.created", - "session": {"id": "sess_mock"} + "type": "session.updated", + "session": {"id": "sess_mock", "instructions": "backend prompt"} }) .to_string() .into(), )) .await - .expect("send session.created"); + .expect("send session.updated"); let second = ws .next() @@ -649,7 +815,7 @@ mod tests { .into_text() .expect("text"); let second_json: Value = serde_json::from_str(&second).expect("json"); - assert_eq!(second_json["type"], "response.input_audio.delta"); + assert_eq!(second_json["type"], "input_audio_buffer.append"); let third = ws .next() @@ -662,12 +828,24 @@ mod tests { assert_eq!(third_json["type"], "conversation.item.create"); assert_eq!(third_json["item"]["content"][0]["text"], "hello agent"); + let fourth = ws + .next() + .await + .expect("fourth msg") + .expect("fourth msg ok") + .into_text() + .expect("text"); + let fourth_json: Value = serde_json::from_str(&fourth).expect("json"); + assert_eq!(fourth_json["type"], "conversation.handoff.append"); + assert_eq!(fourth_json["handoff_id"], "handoff_1"); + assert_eq!(fourth_json["output_text"], "hello from codex"); + ws.send(Message::Text( json!({ - "type": "response.output_audio.delta", + "type": "conversation.output_audio.delta", "delta": "AQID", "sample_rate": 48000, - "num_channels": 1 + "channels": 1 }) .to_string() .into(), @@ -677,8 +855,11 @@ mod tests { ws.send(Message::Text( json!({ - "type": "conversation.item.added", - "item": {"type": "spawn_transcript", "seq": 2} + "type": "conversation.handoff.requested", + "handoff_id": "handoff_1", + "item_id": "item_2", + "input_transcript": "delegate now", + "messages": [{"role": "user", "text": "delegate now"}] }) .to_string() .into(), @@ -705,7 +886,8 @@ mod tests { let connection = client .connect( RealtimeSessionConfig { - prompt: "backend prompt".to_string(), + instructions: "backend prompt".to_string(), + model: Some("realtime-test-model".to_string()), session_id: Some("conv_1".to_string()), }, HeaderMap::new(), @@ -721,8 +903,9 @@ mod tests { .expect("event"); assert_eq!( created, - RealtimeEvent::SessionCreated { - session_id: "sess_mock".to_string() + RealtimeEvent::SessionUpdated { + session_id: "sess_mock".to_string(), + instructions: Some("backend prompt".to_string()), } ); @@ -739,6 +922,13 @@ mod tests { .send_conversation_item_create("hello agent".to_string()) .await .expect("send item"); + connection + .send_conversation_handoff_append( + "handoff_1".to_string(), + "hello from codex".to_string(), + ) + .await + .expect("send handoff"); let audio_event = connection .next_event() @@ -762,10 +952,15 @@ mod tests { .expect("event"); assert_eq!( added_event, - RealtimeEvent::ConversationItemAdded(json!({ - "type": "spawn_transcript", - "seq": 2 - })) + RealtimeEvent::HandoffRequested(RealtimeHandoffRequested { + handoff_id: "handoff_1".to_string(), + item_id: "item_2".to_string(), + input_transcript: "delegate now".to_string(), + messages: vec![RealtimeHandoffMessage { + role: "user".to_string(), + text: "delegate now".to_string(), + }], + }) ); connection.close().await.expect("close"); @@ -789,7 +984,7 @@ mod tests { .into_text() .expect("text"); let first_json: Value = serde_json::from_str(&first).expect("json"); - assert_eq!(first_json["type"], "session.create"); + assert_eq!(first_json["type"], "session.update"); let second = ws .next() @@ -799,18 +994,18 @@ mod tests { .into_text() .expect("text"); let second_json: Value = serde_json::from_str(&second).expect("json"); - assert_eq!(second_json["type"], "response.input_audio.delta"); + assert_eq!(second_json["type"], "input_audio_buffer.append"); ws.send(Message::Text( json!({ - "type": "session.created", - "session": {"id": "sess_after_send"} + "type": "session.updated", + "session": {"id": "sess_after_send", "instructions": "backend prompt"} }) .to_string() .into(), )) .await - .expect("send session.created"); + .expect("send session.updated"); }); let provider = Provider { @@ -831,7 +1026,8 @@ mod tests { let connection = client .connect( RealtimeSessionConfig { - prompt: "backend prompt".to_string(), + instructions: "backend prompt".to_string(), + model: Some("realtime-test-model".to_string()), session_id: Some("conv_1".to_string()), }, HeaderMap::new(), @@ -862,8 +1058,9 @@ mod tests { let next_event = next_result.expect("next event").expect("event"); assert_eq!( next_event, - RealtimeEvent::SessionCreated { - session_id: "sess_after_send".to_string() + RealtimeEvent::SessionUpdated { + session_id: "sess_after_send".to_string(), + instructions: Some("backend prompt".to_string()), } ); diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol.rs index f2f0616fc6..82957c5ec4 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol.rs @@ -1,49 +1,63 @@ pub use codex_protocol::protocol::RealtimeAudioFrame; pub use codex_protocol::protocol::RealtimeEvent; +pub use codex_protocol::protocol::RealtimeHandoffMessage; +pub use codex_protocol::protocol::RealtimeHandoffRequested; use serde::Serialize; use serde_json::Value; use tracing::debug; #[derive(Debug, Clone, PartialEq, Eq)] pub struct RealtimeSessionConfig { - pub prompt: String, + pub instructions: String, + pub model: Option, pub session_id: Option, } #[derive(Debug, Clone, Serialize)] #[serde(tag = "type")] pub(super) enum RealtimeOutboundMessage { - #[serde(rename = "response.input_audio.delta")] - InputAudioDelta { - delta: String, - sample_rate: u32, - num_channels: u16, - #[serde(skip_serializing_if = "Option::is_none")] - samples_per_channel: Option, + #[serde(rename = "input_audio_buffer.append")] + InputAudioBufferAppend { audio: String }, + #[serde(rename = "conversation.handoff.append")] + ConversationHandoffAppend { + handoff_id: String, + output_text: String, }, - #[serde(rename = "session.create")] - SessionCreate { session: SessionCreateSession }, #[serde(rename = "session.update")] - SessionUpdate { - #[serde(skip_serializing_if = "Option::is_none")] - session: Option, - }, + SessionUpdate { session: SessionUpdateSession }, #[serde(rename = "conversation.item.create")] ConversationItemCreate { item: ConversationItem }, } #[derive(Debug, Clone, Serialize)] pub(super) struct SessionUpdateSession { - pub(super) backend_prompt: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub(super) conversation_id: Option, + #[serde(rename = "type")] + pub(super) kind: String, + pub(super) instructions: String, + pub(super) audio: SessionAudio, } #[derive(Debug, Clone, Serialize)] -pub(super) struct SessionCreateSession { - pub(super) backend_prompt: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub(super) conversation_id: Option, +pub(super) struct SessionAudio { + pub(super) input: SessionAudioInput, + pub(super) output: SessionAudioOutput, +} + +#[derive(Debug, Clone, Serialize)] +pub(super) struct SessionAudioInput { + pub(super) format: SessionAudioFormat, +} + +#[derive(Debug, Clone, Serialize)] +pub(super) struct SessionAudioFormat { + #[serde(rename = "type")] + pub(super) kind: String, + pub(super) rate: u32, +} + +#[derive(Debug, Clone, Serialize)] +pub(super) struct SessionAudioOutput { + pub(super) voice: String, } #[derive(Debug, Clone, Serialize)] @@ -78,30 +92,25 @@ pub(super) fn parse_realtime_event(payload: &str) -> Option { } }; match message_type { - "session.created" => { - let session = parsed.get("session").and_then(Value::as_object); - let session_id = session - .and_then(|session| session.get("id")) - .and_then(Value::as_str) - .map(str::to_string) - .or_else(|| { - parsed - .get("session_id") - .and_then(Value::as_str) - .map(str::to_string) - }); - session_id.map(|id| RealtimeEvent::SessionCreated { session_id: id }) - } "session.updated" => { - let backend_prompt = parsed + let session_id = parsed .get("session") .and_then(Value::as_object) - .and_then(|session| session.get("backend_prompt")) + .and_then(|session| session.get("id")) .and_then(Value::as_str) .map(str::to_string); - Some(RealtimeEvent::SessionUpdated { backend_prompt }) + let instructions = parsed + .get("session") + .and_then(Value::as_object) + .and_then(|session| session.get("instructions")) + .and_then(Value::as_str) + .map(str::to_string); + session_id.map(|session_id| RealtimeEvent::SessionUpdated { + session_id, + instructions, + }) } - "response.output_audio.delta" => { + "conversation.output_audio.delta" => { let data = parsed .get("delta") .and_then(Value::as_str) @@ -112,7 +121,8 @@ pub(super) fn parse_realtime_event(payload: &str) -> Option { .and_then(Value::as_u64) .and_then(|v| u32::try_from(v).ok())?; let num_channels = parsed - .get("num_channels") + .get("channels") + .or_else(|| parsed.get("num_channels")) .and_then(Value::as_u64) .and_then(|v| u16::try_from(v).ok())?; Some(RealtimeEvent::AudioOut(RealtimeAudioFrame { @@ -129,10 +139,55 @@ pub(super) fn parse_realtime_event(payload: &str) -> Option { .get("item") .cloned() .map(RealtimeEvent::ConversationItemAdded), + "conversation.item.done" => parsed + .get("item") + .and_then(Value::as_object) + .and_then(|item| item.get("id")) + .and_then(Value::as_str) + .map(str::to_string) + .map(|item_id| RealtimeEvent::ConversationItemDone { item_id }), + "conversation.handoff.requested" => { + let handoff_id = parsed + .get("handoff_id") + .and_then(Value::as_str) + .map(str::to_string)?; + let item_id = parsed + .get("item_id") + .and_then(Value::as_str) + .map(str::to_string)?; + let input_transcript = parsed + .get("input_transcript") + .and_then(Value::as_str) + .map(str::to_string)?; + let messages = parsed + .get("messages") + .and_then(Value::as_array)? + .iter() + .filter_map(|message| { + let role = message.get("role").and_then(Value::as_str)?.to_string(); + let text = message.get("text").and_then(Value::as_str)?.to_string(); + Some(RealtimeHandoffMessage { role, text }) + }) + .collect(); + Some(RealtimeEvent::HandoffRequested(RealtimeHandoffRequested { + handoff_id, + item_id, + input_transcript, + messages, + })) + } "error" => parsed .get("message") .and_then(Value::as_str) .map(str::to_string) + .or_else(|| { + parsed + .get("error") + .and_then(Value::as_object) + .and_then(|error| error.get("message")) + .and_then(Value::as_str) + .map(str::to_string) + }) .or_else(|| parsed.get("error").map(std::string::ToString::to_string)) .map(RealtimeEvent::Error), _ => { diff --git a/codex-rs/codex-api/src/lib.rs b/codex-rs/codex-api/src/lib.rs index b9152ec576..138929602b 100644 --- a/codex-rs/codex-api/src/lib.rs +++ b/codex-rs/codex-api/src/lib.rs @@ -19,7 +19,6 @@ pub use crate::common::MemorySummarizeInput; pub use crate::common::MemorySummarizeOutput; pub use crate::common::RawMemory; pub use crate::common::RawMemoryMetadata; -pub use crate::common::ResponseAppendWsRequest; pub use crate::common::ResponseCreateWsRequest; pub use crate::common::ResponseEvent; pub use crate::common::ResponseStream; diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs index 3c039380c9..d01e1d918a 100644 --- a/codex-rs/codex-api/src/sse/responses.rs +++ b/codex-rs/codex-api/src/sse/responses.rs @@ -118,14 +118,6 @@ struct ResponseCompleted { usage: Option, } -#[derive(Debug, Deserialize)] -struct ResponseDone { - #[serde(default)] - id: Option, - #[serde(default)] - usage: Option, -} - #[derive(Debug, Deserialize)] struct ResponseCompletedUsage { input_tokens: i64, @@ -324,7 +316,6 @@ pub fn process_responses_event( return Ok(Some(ResponseEvent::Completed { response_id: resp.id, token_usage: resp.usage.map(Into::into), - can_append: false, })); } Err(err) => { @@ -335,31 +326,6 @@ pub fn process_responses_event( } } } - "response.done" => { - if let Some(resp_val) = event.response { - match serde_json::from_value::(resp_val) { - Ok(resp) => { - return Ok(Some(ResponseEvent::Completed { - response_id: resp.id.unwrap_or_default(), - token_usage: resp.usage.map(Into::into), - can_append: true, - })); - } - Err(err) => { - let error = format!("failed to parse ResponseCompleted: {err}"); - debug!("{error}"); - return Err(ResponsesEventError::Api(ApiError::Stream(error))); - } - } - } - - debug!("response.done missing response payload"); - return Ok(Some(ResponseEvent::Completed { - response_id: String::new(), - token_usage: None, - can_append: true, - })); - } "response.output_item.added" => { if let Some(item_val) = event.item { if let Ok(item) = serde_json::from_value::(item_val) { @@ -639,11 +605,9 @@ mod tests { Ok(ResponseEvent::Completed { response_id, token_usage, - can_append, }) => { assert_eq!(response_id, "resp1"); assert!(token_usage.is_none()); - assert!(!can_append); } other => panic!("unexpected third event: {other:?}"), } @@ -677,69 +641,6 @@ mod tests { } } - #[tokio::test] - async fn response_done_emits_incremental_completed() { - let done = json!({ - "type": "response.done", - "response": { - "usage": { - "input_tokens": 1, - "input_tokens_details": null, - "output_tokens": 2, - "output_tokens_details": null, - "total_tokens": 3 - } - } - }) - .to_string(); - - let sse1 = format!("event: response.done\ndata: {done}\n\n"); - - let events = collect_events(&[sse1.as_bytes()]).await; - - assert_eq!(events.len(), 1); - - match &events[0] { - Ok(ResponseEvent::Completed { - response_id, - token_usage, - can_append, - }) => { - assert_eq!(response_id, ""); - assert!(token_usage.is_some()); - assert!(*can_append); - } - other => panic!("unexpected event: {other:?}"), - } - } - - #[tokio::test] - async fn response_done_without_payload_emits_completed() { - let done = json!({ - "type": "response.done" - }) - .to_string(); - - let sse1 = format!("event: response.done\ndata: {done}\n\n"); - - let events = collect_events(&[sse1.as_bytes()]).await; - - assert_eq!(events.len(), 1); - - match &events[0] { - Ok(ResponseEvent::Completed { - response_id, - token_usage, - can_append, - }) => { - assert_eq!(response_id, ""); - assert!(token_usage.is_none()); - assert!(*can_append); - } - other => panic!("unexpected event: {other:?}"), - } - } - #[tokio::test] async fn emits_completed_without_stream_end() { let completed = json!({ @@ -770,11 +671,9 @@ mod tests { Ok(ResponseEvent::Completed { response_id, token_usage, - can_append, }) => { assert_eq!(response_id, "resp1"); assert!(token_usage.is_none()); - assert!(!can_append); } other => panic!("unexpected event: {other:?}"), } @@ -996,8 +895,7 @@ mod tests { &events[1], ResponseEvent::Completed { response_id, - token_usage: None, - can_append: false + token_usage: None } if response_id == "resp-1" ); } @@ -1033,8 +931,7 @@ mod tests { &events[2], ResponseEvent::Completed { response_id, - token_usage: None, - can_append: false + token_usage: None } if response_id == "resp-1" ); } diff --git a/codex-rs/codex-api/tests/clients.rs b/codex-rs/codex-api/tests/clients.rs index 955ccf3fa7..63a033dea3 100644 --- a/codex-rs/codex-api/tests/clients.rs +++ b/codex-rs/codex-api/tests/clients.rs @@ -265,6 +265,7 @@ async fn streaming_client_retries_on_transport_error() -> Result<()> { store: false, stream: true, include: Vec::new(), + service_tier: None, prompt_cache_key: None, text: None, }; @@ -306,6 +307,7 @@ async fn azure_default_store_attaches_ids_and_headers() -> Result<()> { store: true, stream: true, include: Vec::new(), + service_tier: None, prompt_cache_key: None, text: None, }; diff --git a/codex-rs/codex-api/tests/realtime_websocket_e2e.rs b/codex-rs/codex-api/tests/realtime_websocket_e2e.rs index ae8b082b93..aa11b79584 100644 --- a/codex-rs/codex-api/tests/realtime_websocket_e2e.rs +++ b/codex-rs/codex-api/tests/realtime_websocket_e2e.rs @@ -78,26 +78,34 @@ async fn realtime_ws_e2e_session_create_and_event_flow() { .into_text() .expect("text"); let first_json: Value = serde_json::from_str(&first).expect("json"); - assert_eq!(first_json["type"], "session.create"); + assert_eq!(first_json["type"], "session.update"); assert_eq!( - first_json["session"]["backend_prompt"], + first_json["session"]["type"], + Value::String("quicksilver".to_string()) + ); + assert_eq!( + first_json["session"]["instructions"], Value::String("backend prompt".to_string()) ); assert_eq!( - first_json["session"]["conversation_id"], - Value::String("conv_123".to_string()) + first_json["session"]["audio"]["input"]["format"]["type"], + Value::String("audio/pcm".to_string()) + ); + assert_eq!( + first_json["session"]["audio"]["input"]["format"]["rate"], + Value::from(24_000) ); ws.send(Message::Text( json!({ - "type": "session.created", - "session": {"id": "sess_mock"} + "type": "session.updated", + "session": {"id": "sess_mock", "instructions": "backend prompt"} }) .to_string() .into(), )) .await - .expect("send session.created"); + .expect("send session.updated"); let second = ws .next() @@ -107,14 +115,14 @@ async fn realtime_ws_e2e_session_create_and_event_flow() { .into_text() .expect("text"); let second_json: Value = serde_json::from_str(&second).expect("json"); - assert_eq!(second_json["type"], "response.input_audio.delta"); + assert_eq!(second_json["type"], "input_audio_buffer.append"); ws.send(Message::Text( json!({ - "type": "response.output_audio.delta", + "type": "conversation.output_audio.delta", "delta": "AQID", "sample_rate": 48000, - "num_channels": 1 + "channels": 1 }) .to_string() .into(), @@ -128,7 +136,8 @@ async fn realtime_ws_e2e_session_create_and_event_flow() { let connection = client .connect( RealtimeSessionConfig { - prompt: "backend prompt".to_string(), + instructions: "backend prompt".to_string(), + model: Some("realtime-test-model".to_string()), session_id: Some("conv_123".to_string()), }, HeaderMap::new(), @@ -144,8 +153,9 @@ async fn realtime_ws_e2e_session_create_and_event_flow() { .expect("event"); assert_eq!( created, - RealtimeEvent::SessionCreated { - session_id: "sess_mock".to_string() + RealtimeEvent::SessionUpdated { + session_id: "sess_mock".to_string(), + instructions: Some("backend prompt".to_string()), } ); @@ -189,7 +199,7 @@ async fn realtime_ws_e2e_send_while_next_event_waits() { .into_text() .expect("text"); let first_json: Value = serde_json::from_str(&first).expect("json"); - assert_eq!(first_json["type"], "session.create"); + assert_eq!(first_json["type"], "session.update"); let second = ws .next() @@ -199,18 +209,18 @@ async fn realtime_ws_e2e_send_while_next_event_waits() { .into_text() .expect("text"); let second_json: Value = serde_json::from_str(&second).expect("json"); - assert_eq!(second_json["type"], "response.input_audio.delta"); + assert_eq!(second_json["type"], "input_audio_buffer.append"); ws.send(Message::Text( json!({ - "type": "session.created", - "session": {"id": "sess_after_send"} + "type": "session.updated", + "session": {"id": "sess_after_send", "instructions": "backend prompt"} }) .to_string() .into(), )) .await - .expect("send session.created"); + .expect("send session.updated"); }) .await; @@ -218,7 +228,8 @@ async fn realtime_ws_e2e_send_while_next_event_waits() { let connection = client .connect( RealtimeSessionConfig { - prompt: "backend prompt".to_string(), + instructions: "backend prompt".to_string(), + model: Some("realtime-test-model".to_string()), session_id: Some("conv_123".to_string()), }, HeaderMap::new(), @@ -249,8 +260,9 @@ async fn realtime_ws_e2e_send_while_next_event_waits() { let next_event = next_result.expect("next event").expect("event"); assert_eq!( next_event, - RealtimeEvent::SessionCreated { - session_id: "sess_after_send".to_string() + RealtimeEvent::SessionUpdated { + session_id: "sess_after_send".to_string(), + instructions: Some("backend prompt".to_string()), } ); @@ -269,7 +281,7 @@ async fn realtime_ws_e2e_disconnected_emitted_once() { .into_text() .expect("text"); let first_json: Value = serde_json::from_str(&first).expect("json"); - assert_eq!(first_json["type"], "session.create"); + assert_eq!(first_json["type"], "session.update"); ws.send(Message::Close(None)).await.expect("send close"); }) @@ -279,7 +291,8 @@ async fn realtime_ws_e2e_disconnected_emitted_once() { let connection = client .connect( RealtimeSessionConfig { - prompt: "backend prompt".to_string(), + instructions: "backend prompt".to_string(), + model: Some("realtime-test-model".to_string()), session_id: Some("conv_123".to_string()), }, HeaderMap::new(), @@ -308,7 +321,7 @@ async fn realtime_ws_e2e_ignores_unknown_text_events() { .into_text() .expect("text"); let first_json: Value = serde_json::from_str(&first).expect("json"); - assert_eq!(first_json["type"], "session.create"); + assert_eq!(first_json["type"], "session.update"); ws.send(Message::Text( json!({ @@ -323,14 +336,14 @@ async fn realtime_ws_e2e_ignores_unknown_text_events() { ws.send(Message::Text( json!({ - "type": "session.created", - "session": {"id": "sess_after_unknown"} + "type": "session.updated", + "session": {"id": "sess_after_unknown", "instructions": "backend prompt"} }) .to_string() .into(), )) .await - .expect("send session.created"); + .expect("send session.updated"); }) .await; @@ -338,7 +351,8 @@ async fn realtime_ws_e2e_ignores_unknown_text_events() { let connection = client .connect( RealtimeSessionConfig { - prompt: "backend prompt".to_string(), + instructions: "backend prompt".to_string(), + model: Some("realtime-test-model".to_string()), session_id: Some("conv_123".to_string()), }, HeaderMap::new(), @@ -354,8 +368,9 @@ async fn realtime_ws_e2e_ignores_unknown_text_events() { .expect("event"); assert_eq!( event, - RealtimeEvent::SessionCreated { - session_id: "sess_after_unknown".to_string() + RealtimeEvent::SessionUpdated { + session_id: "sess_after_unknown".to_string(), + instructions: Some("backend prompt".to_string()), } ); diff --git a/codex-rs/codex-api/tests/sse_end_to_end.rs b/codex-rs/codex-api/tests/sse_end_to_end.rs index ca840d1b9f..f625fdcd9d 100644 --- a/codex-rs/codex-api/tests/sse_end_to_end.rs +++ b/codex-rs/codex-api/tests/sse_end_to_end.rs @@ -159,11 +159,9 @@ async fn responses_stream_parses_items_and_completed_end_to_end() -> Result<()> ResponseEvent::Completed { response_id, token_usage, - can_append, } => { assert_eq!(response_id, "resp1"); assert!(token_usage.is_none()); - assert!(!can_append); } other => panic!("unexpected third event: {other:?}"), } diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 87f18eb800..c3a37bb989 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -42,6 +42,8 @@ codex-hooks = { workspace = true } codex-keyring-store = { workspace = true } codex-network-proxy = { workspace = true } codex-otel = { workspace = true } +codex-artifact-presentation = { workspace = true } +codex-artifact-spreadsheet = { workspace = true } codex-protocol = { workspace = true } codex-rmcp-client = { workspace = true } codex-state = { workspace = true } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 689538102c..3cd7000913 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -313,6 +313,9 @@ "apps_mcp_gateway": { "type": "boolean" }, + "artifact": { + "type": "boolean" + }, "child_agents_md": { "type": "boolean" }, @@ -349,6 +352,9 @@ "experimental_windows_sandbox": { "type": "boolean" }, + "fast_mode": { + "type": "boolean" + }, "include_apply_patch_tool": { "type": "boolean" }, @@ -505,6 +511,9 @@ "sandbox_mode": { "$ref": "#/definitions/SandboxMode" }, + "service_tier": { + "$ref": "#/definitions/ServiceTier" + }, "tools_view_image": { "type": "boolean" }, @@ -1321,6 +1330,12 @@ }, "type": "object" }, + "ServiceTier": { + "enum": [ + "fast" + ], + "type": "string" + }, "ShellEnvironmentPolicyInherit": { "oneOf": [ { @@ -1657,11 +1672,15 @@ "$ref": "#/definitions/AbsolutePathBuf" }, "experimental_realtime_ws_backend_prompt": { - "description": "Experimental / do not use. Overrides only the realtime conversation websocket transport backend prompt (the `Op::RealtimeConversation` `/ws` session.create backend_prompt) without changing normal prompts.", + "description": "Experimental / do not use. Overrides only the realtime conversation websocket transport instructions (the `Op::RealtimeConversation` `/ws` session.update instructions) without changing normal prompts.", "type": "string" }, "experimental_realtime_ws_base_url": { - "description": "Experimental / do not use. Overrides only the realtime conversation websocket transport base URL (the `Op::RealtimeConversation` `/ws` connection) without changing normal provider HTTP requests.", + "description": "Experimental / do not use. Overrides only the realtime conversation websocket transport base URL (the `Op::RealtimeConversation` `/v1/realtime` connection) without changing normal provider HTTP requests.", + "type": "string" + }, + "experimental_realtime_ws_model": { + "description": "Experimental / do not use. Selects the realtime websocket model/snapshot used for the `Op::RealtimeConversation` connection.", "type": "string" }, "experimental_use_freeform_apply_patch": { @@ -1684,6 +1703,9 @@ "apps_mcp_gateway": { "type": "boolean" }, + "artifact": { + "type": "boolean" + }, "child_agents_md": { "type": "boolean" }, @@ -1720,6 +1742,9 @@ "experimental_windows_sandbox": { "type": "boolean" }, + "fast_mode": { + "type": "boolean" + }, "include_apply_patch_tool": { "type": "boolean" }, @@ -2108,6 +2133,14 @@ ], "description": "Sandbox configuration to apply if `sandbox` is `WorkspaceWrite`." }, + "service_tier": { + "allOf": [ + { + "$ref": "#/definitions/ServiceTier" + } + ], + "description": "Optional explicit service tier preference for new turns." + }, "shell_environment_policy": { "allOf": [ { diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 6327e1e3e0..85f28b4f6b 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -405,18 +405,20 @@ impl AgentControl { }; let control = self.clone(); tokio::spawn(async move { - let mut status_rx = match control.subscribe_status(child_thread_id).await { - Ok(rx) => rx, - Err(_) => return, - }; - let mut status = status_rx.borrow().clone(); - while !is_final(&status) { - if status_rx.changed().await.is_err() { - status = control.get_status(child_thread_id).await; - break; + let status = match control.subscribe_status(child_thread_id).await { + Ok(mut status_rx) => { + let mut status = status_rx.borrow().clone(); + while !is_final(&status) { + if status_rx.changed().await.is_err() { + status = control.get_status(child_thread_id).await; + break; + } + status = status_rx.borrow().clone(); + } + status } - status = status_rx.borrow().clone(); - } + Err(_) => control.get_status(child_thread_id).await, + }; if !is_final(&status) { return; } @@ -1296,6 +1298,44 @@ mod tests { assert_eq!(wait_for_subagent_notification(&parent_thread).await, true); } + #[tokio::test] + async fn completion_watcher_notifies_parent_when_child_is_missing() { + let harness = AgentControlHarness::new().await; + let (parent_thread_id, parent_thread) = harness.start_thread().await; + let child_thread_id = ThreadId::new(); + + harness.control.maybe_start_completion_watcher( + child_thread_id, + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_nickname: None, + agent_role: Some("explorer".to_string()), + })), + ); + + assert_eq!(wait_for_subagent_notification(&parent_thread).await, true); + + let history_items = parent_thread + .codex + .session + .clone_history() + .await + .raw_items() + .to_vec(); + assert_eq!( + history_contains_text( + &history_items, + &format!("\"agent_id\":\"{child_thread_id}\"") + ), + true + ); + assert_eq!( + history_contains_text(&history_items, "\"status\":\"not_found\""), + true + ); + } + #[tokio::test] async fn spawn_thread_subagent_gets_random_nickname_in_session_source() { let harness = AgentControlHarness::new().await; diff --git a/codex-rs/core/src/agent/role.rs b/codex-rs/core/src/agent/role.rs index 0ff5a7d560..3ca496d6e1 100644 --- a/codex-rs/core/src/agent/role.rs +++ b/codex-rs/core/src/agent/role.rs @@ -1,3 +1,11 @@ +//! Applies agent-role configuration layers on top of an existing session config. +//! +//! Roles are selected at spawn time and are loaded with the same config machinery as +//! `config.toml`. This module resolves built-in and user-defined role files, inserts the role as a +//! high-precedence layer, and preserves the caller's current profile/provider unless the role +//! explicitly takes ownership of model selection. It does not decide when to spawn a sub-agent or +//! which role to use; the multi-agent tool handler owns that orchestration. + use crate::config::AgentRoleConfig; use crate::config::Config; use crate::config::ConfigOverrides; @@ -13,10 +21,18 @@ use std::path::Path; use std::sync::LazyLock; use toml::Value as TomlValue; +/// The role name used when a caller omits `agent_type`. pub const DEFAULT_ROLE_NAME: &str = "default"; const AGENT_TYPE_UNAVAILABLE_ERROR: &str = "agent type is currently not available"; -/// Applies a role config layer to a mutable config and preserves unspecified keys. +/// Applies a named role layer to `config` while preserving caller-owned model selection. +/// +/// The role layer is inserted at session-flag precedence so it can override persisted config, but +/// the caller's current `profile` and `model_provider` remain sticky runtime choices unless the +/// role explicitly sets `profile`, explicitly sets `model_provider`, or rewrites the active +/// profile's `model_provider` in place. Rebuilding the config without those overrides would make a +/// spawned agent silently fall back to the default provider, which is the bug this preservation +/// logic avoids. pub(crate) async fn apply_role_to_config( config: &mut Config, role_name: Option<&str>, @@ -60,6 +76,25 @@ pub(crate) async fn apply_role_to_config( .map_err(|_| AGENT_TYPE_UNAVAILABLE_ERROR.to_string())?; let role_layer_toml = resolve_relative_paths_in_config_toml(role_config_toml, role_config_base) .map_err(|_| AGENT_TYPE_UNAVAILABLE_ERROR.to_string())?; + let role_selects_provider = role_layer_toml.get("model_provider").is_some(); + let role_selects_profile = role_layer_toml.get("profile").is_some(); + let role_updates_active_profile_provider = config + .active_profile + .as_ref() + .and_then(|active_profile| { + role_layer_toml + .get("profiles") + .and_then(TomlValue::as_table) + .and_then(|profiles| profiles.get(active_profile)) + .and_then(TomlValue::as_table) + .map(|profile| profile.contains_key("model_provider")) + }) + .unwrap_or(false); + // A role that does not explicitly take ownership of model selection should inherit the + // caller's current profile/provider choices across the config reload. + let preserve_current_profile = !role_selects_provider && !role_selects_profile; + let preserve_current_provider = + preserve_current_profile && !role_updates_active_profile_provider; let mut layers: Vec = config .config_layer_stack @@ -86,6 +121,10 @@ pub(crate) async fn apply_role_to_config( merged_config, ConfigOverrides { cwd: Some(config.cwd.clone()), + model_provider: preserve_current_provider.then(|| config.model_provider_id.clone()), + config_profile: preserve_current_profile + .then(|| config.active_profile.clone()) + .flatten(), codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), js_repl_node_path: config.js_repl_node_path.clone(), @@ -225,6 +264,7 @@ Rules: #[cfg(test)] mod tests { use super::*; + use crate::config::CONFIG_TOML_FILE; use crate::config::ConfigBuilder; use crate::config_loader::ConfigLayerStackOrdering; use crate::plugins::PluginsManager; @@ -382,6 +422,227 @@ mod tests { ); } + #[tokio::test] + async fn apply_role_preserves_active_profile_and_model_provider() { + let home = TempDir::new().expect("create temp dir"); + tokio::fs::write( + home.path().join(CONFIG_TOML_FILE), + r#" +[model_providers.test-provider] +name = "Test Provider" +base_url = "https://example.com/v1" +env_key = "TEST_PROVIDER_API_KEY" +wire_api = "responses" + +[profiles.test-profile] +model_provider = "test-provider" +"#, + ) + .await + .expect("write config.toml"); + let mut config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + config_profile: Some("test-profile".to_string()), + ..Default::default() + }) + .fallback_cwd(Some(home.path().to_path_buf())) + .build() + .await + .expect("load config"); + let role_path = write_role_config(&home, "empty-role.toml", "").await; + config.agent_roles.insert( + "custom".to_string(), + AgentRoleConfig { + description: None, + config_file: Some(role_path), + }, + ); + + apply_role_to_config(&mut config, Some("custom")) + .await + .expect("custom role should apply"); + + assert_eq!(config.active_profile.as_deref(), Some("test-profile")); + assert_eq!(config.model_provider_id, "test-provider"); + assert_eq!(config.model_provider.name, "Test Provider"); + } + + #[tokio::test] + async fn apply_role_uses_role_profile_instead_of_current_profile() { + let home = TempDir::new().expect("create temp dir"); + tokio::fs::write( + home.path().join(CONFIG_TOML_FILE), + r#" +[model_providers.base-provider] +name = "Base Provider" +base_url = "https://base.example.com/v1" +env_key = "BASE_PROVIDER_API_KEY" +wire_api = "responses" + +[model_providers.role-provider] +name = "Role Provider" +base_url = "https://role.example.com/v1" +env_key = "ROLE_PROVIDER_API_KEY" +wire_api = "responses" + +[profiles.base-profile] +model_provider = "base-provider" + +[profiles.role-profile] +model_provider = "role-provider" +"#, + ) + .await + .expect("write config.toml"); + let mut config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + config_profile: Some("base-profile".to_string()), + ..Default::default() + }) + .fallback_cwd(Some(home.path().to_path_buf())) + .build() + .await + .expect("load config"); + let role_path = + write_role_config(&home, "profile-role.toml", "profile = \"role-profile\"").await; + config.agent_roles.insert( + "custom".to_string(), + AgentRoleConfig { + description: None, + config_file: Some(role_path), + }, + ); + + apply_role_to_config(&mut config, Some("custom")) + .await + .expect("custom role should apply"); + + assert_eq!(config.active_profile.as_deref(), Some("role-profile")); + assert_eq!(config.model_provider_id, "role-provider"); + assert_eq!(config.model_provider.name, "Role Provider"); + } + + #[tokio::test] + async fn apply_role_uses_role_model_provider_instead_of_current_profile_provider() { + let home = TempDir::new().expect("create temp dir"); + tokio::fs::write( + home.path().join(CONFIG_TOML_FILE), + r#" +[model_providers.base-provider] +name = "Base Provider" +base_url = "https://base.example.com/v1" +env_key = "BASE_PROVIDER_API_KEY" +wire_api = "responses" + +[model_providers.role-provider] +name = "Role Provider" +base_url = "https://role.example.com/v1" +env_key = "ROLE_PROVIDER_API_KEY" +wire_api = "responses" + +[profiles.base-profile] +model_provider = "base-provider" +"#, + ) + .await + .expect("write config.toml"); + let mut config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + config_profile: Some("base-profile".to_string()), + ..Default::default() + }) + .fallback_cwd(Some(home.path().to_path_buf())) + .build() + .await + .expect("load config"); + let role_path = write_role_config( + &home, + "provider-role.toml", + "model_provider = \"role-provider\"", + ) + .await; + config.agent_roles.insert( + "custom".to_string(), + AgentRoleConfig { + description: None, + config_file: Some(role_path), + }, + ); + + apply_role_to_config(&mut config, Some("custom")) + .await + .expect("custom role should apply"); + + assert_eq!(config.active_profile, None); + assert_eq!(config.model_provider_id, "role-provider"); + assert_eq!(config.model_provider.name, "Role Provider"); + } + + #[tokio::test] + async fn apply_role_uses_active_profile_model_provider_update() { + let home = TempDir::new().expect("create temp dir"); + tokio::fs::write( + home.path().join(CONFIG_TOML_FILE), + r#" +[model_providers.base-provider] +name = "Base Provider" +base_url = "https://base.example.com/v1" +env_key = "BASE_PROVIDER_API_KEY" +wire_api = "responses" + +[model_providers.role-provider] +name = "Role Provider" +base_url = "https://role.example.com/v1" +env_key = "ROLE_PROVIDER_API_KEY" +wire_api = "responses" + +[profiles.base-profile] +model_provider = "base-provider" +model_reasoning_effort = "low" +"#, + ) + .await + .expect("write config.toml"); + let mut config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + config_profile: Some("base-profile".to_string()), + ..Default::default() + }) + .fallback_cwd(Some(home.path().to_path_buf())) + .build() + .await + .expect("load config"); + let role_path = write_role_config( + &home, + "profile-edit-role.toml", + r#"[profiles.base-profile] +model_provider = "role-provider" +model_reasoning_effort = "high" +"#, + ) + .await; + config.agent_roles.insert( + "custom".to_string(), + AgentRoleConfig { + description: None, + config_file: Some(role_path), + }, + ); + + apply_role_to_config(&mut config, Some("custom")) + .await + .expect("custom role should apply"); + + assert_eq!(config.active_profile.as_deref(), Some("base-profile")); + assert_eq!(config.model_provider_id, "role-provider"); + assert_eq!(config.model_provider.name, "Role Provider"); + assert_eq!(config.model_reasoning_effort, Some(ReasoningEffort::High)); + } + #[tokio::test] #[cfg(not(windows))] async fn apply_role_does_not_materialize_default_sandbox_workspace_write_fields() { diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 26fbf5e07b..9e5974b487 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -20,9 +20,8 @@ //! //! ## Retry-Budget Tradeoff //! -//! V2 request prewarm is treated as the first websocket connection attempt for a turn. If it -//! fails, normal stream retry/fallback logic handles recovery on the same turn. V1 prewarm -//! remains connection-only. +//! WebSocket prewarm is treated as the first websocket connection attempt for a turn. If it +//! fails, normal stream retry/fallback logic handles recovery on the same turn. use std::collections::HashMap; use std::sync::Arc; @@ -43,7 +42,6 @@ use codex_api::MemorySummarizeOutput as ApiMemorySummarizeOutput; use codex_api::RawMemory as ApiRawMemory; use codex_api::RequestTelemetry; use codex_api::ReqwestTransport; -use codex_api::ResponseAppendWsRequest; use codex_api::ResponseCreateWsRequest; use codex_api::ResponsesApiRequest; use codex_api::ResponsesClient as ApiResponsesClient; @@ -63,6 +61,7 @@ use codex_otel::OtelManager; use codex_protocol::ThreadId; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; +use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::Verbosity as VerbosityConfig; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelInfo; @@ -100,32 +99,19 @@ use crate::model_provider_info::WireApi; use crate::tools::spec::create_tools_json_for_responses_api; pub const OPENAI_BETA_HEADER: &str = "OpenAI-Beta"; -pub const OPENAI_BETA_RESPONSES_WEBSOCKETS: &str = "responses_websockets=2026-02-04"; pub const X_CODEX_TURN_STATE_HEADER: &str = "x-codex-turn-state"; pub const X_CODEX_TURN_METADATA_HEADER: &str = "x-codex-turn-metadata"; pub const X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER: &str = "x-responsesapi-include-timing-metrics"; const RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE: &str = "responses_websockets=2026-02-06"; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ResponsesWebsocketVersion { - V1, - V2, -} - -pub fn ws_version_from_features(config: &Config) -> Option { - match ( - config +pub fn ws_version_from_features(config: &Config) -> bool { + config + .features + .enabled(crate::features::Feature::ResponsesWebsockets) + || config .features - .enabled(crate::features::Feature::ResponsesWebsockets), - config - .features - .enabled(crate::features::Feature::ResponsesWebsocketsV2), - ) { - (_, true) => Some(ResponsesWebsocketVersion::V2), - (true, false) => Some(ResponsesWebsocketVersion::V1), - (false, false) => None, - } + .enabled(crate::features::Feature::ResponsesWebsocketsV2) } /// Session-scoped state shared by all [`ModelClient`] clones. @@ -139,7 +125,7 @@ struct ModelClientState { provider: ModelProviderInfo, session_source: SessionSource, model_verbosity: Option, - responses_websocket_version: Option, + responses_websockets_enabled_by_feature: bool, enable_request_compression: bool, include_timing_metrics: bool, beta_features_header: Option, @@ -179,8 +165,8 @@ pub struct ModelClient { /// The session establishes a Responses WebSocket connection lazily and reuses it across multiple /// requests within the turn. It also caches per-turn state: /// -/// - The last full request, so subsequent calls can use `response.append` only when the current -/// request is an incremental extension of the previous one. +/// - The last full request, so subsequent calls can reuse incremental websocket request payloads +/// only when the current request is an incremental extension of the previous one. /// - The `x-codex-turn-state` sticky-routing token, which must be replayed for all requests within /// the same turn. /// @@ -207,7 +193,6 @@ pub struct ModelClientSession { struct LastResponse { response_id: String, items_added: Vec, - can_append: bool, } #[derive(Debug, Default)] @@ -234,7 +219,7 @@ impl ModelClient { provider: ModelProviderInfo, session_source: SessionSource, model_verbosity: Option, - responses_websocket_version: Option, + responses_websockets_enabled_by_feature: bool, enable_request_compression: bool, include_timing_metrics: bool, beta_features_header: Option, @@ -246,7 +231,7 @@ impl ModelClient { provider, session_source, model_verbosity, - responses_websocket_version, + responses_websockets_enabled_by_feature, enable_request_compression, include_timing_metrics, beta_features_header, @@ -390,25 +375,21 @@ impl ModelClient { request_telemetry } - /// Returns the active Responses-over-WebSocket version for this session. + /// Returns whether the Responses-over-WebSocket transport is active for this session. /// /// This combines provider capability and feature gating; both must be true for websocket paths /// to be eligible. /// /// If websockets are only enabled via model preference (no explicit feature flag), prefer the /// current v2 behavior. - pub fn active_ws_version(&self, model_info: &ModelInfo) -> Option { + pub fn responses_websocket_enabled(&self, model_info: &ModelInfo) -> bool { if !self.state.provider.supports_websockets || self.state.disable_websockets.load(Ordering::Relaxed) { - return None; + return false; } - match self.state.responses_websocket_version { - Some(version) => Some(version), - None if model_info.prefer_websockets => Some(ResponsesWebsocketVersion::V2), - None => None, - } + self.state.responses_websockets_enabled_by_feature || model_info.prefer_websockets } /// Returns auth + provider configuration resolved from the current session auth state. @@ -441,12 +422,10 @@ impl ModelClient { otel_manager: &OtelManager, api_provider: codex_api::Provider, api_auth: CoreAuthProvider, - ws_version: ResponsesWebsocketVersion, turn_state: Option>>, turn_metadata_header: Option<&str>, ) -> std::result::Result { - let headers = - self.build_websocket_headers(ws_version, turn_state.as_ref(), turn_metadata_header); + let headers = self.build_websocket_headers(turn_state.as_ref(), turn_metadata_header); let websocket_telemetry = ModelClientSession::build_websocket_telemetry(otel_manager); ApiWebSocketResponsesClient::new(api_provider, api_auth) .connect( @@ -464,7 +443,6 @@ impl ModelClient { /// replayed on reconnect within the same turn. fn build_websocket_headers( &self, - ws_version: ResponsesWebsocketVersion, turn_state: Option<&Arc>>, turn_metadata_header: Option<&str>, ) -> ApiHeaderMap { @@ -477,13 +455,9 @@ impl ModelClient { headers.extend(build_conversation_headers(Some( self.state.conversation_id.to_string(), ))); - let responses_websockets_beta_header = match ws_version { - ResponsesWebsocketVersion::V2 => RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE, - ResponsesWebsocketVersion::V1 => OPENAI_BETA_RESPONSES_WEBSOCKETS, - }; headers.insert( OPENAI_BETA_HEADER, - HeaderValue::from_static(responses_websockets_beta_header), + HeaderValue::from_static(RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE), ); if self.state.include_timing_metrics { headers.insert( @@ -520,6 +494,7 @@ impl ModelClientSession { model_info: &ModelInfo, effort: Option, summary: ReasoningSummaryConfig, + service_tier: Option, ) -> Result { let instructions = &prompt.base_instructions.text; let input = prompt.get_formatted_input(); @@ -569,6 +544,10 @@ impl ModelClientSession { store: provider.is_azure_responses_endpoint(), stream: true, include, + service_tier: match service_tier { + Some(ServiceTier::Fast) => Some("priority".to_string()), + _ => None, + }, prompt_cache_key, text, }; @@ -607,8 +586,9 @@ impl ModelClientSession { last_response: Option<&LastResponse>, allow_empty_delta: bool, ) -> Option> { - // Checks whether the current request is an incremental append to the previous request. - // We only append when non-input request fields are unchanged and `input` is a strict + // Checks whether the current request is an incremental extension of the previous request. + // We only reuse an incremental input delta when non-input request fields are unchanged and + // `input` is a strict // extension of the previous known input. Server-returned output items are treated as part // of the baseline so we do not resend them. let previous_request = self.websocket_session.last_request.as_ref()?; @@ -653,42 +633,26 @@ impl ModelClientSession { &mut self, payload: ResponseCreateWsRequest, request: &ResponsesApiRequest, - ws_version: ResponsesWebsocketVersion, ) -> ResponsesWsRequest { let Some(last_response) = self.get_last_response() else { return ResponsesWsRequest::ResponseCreate(payload); }; - let allow_empty_delta = matches!(ws_version, ResponsesWebsocketVersion::V2); - let Some(append_items) = - self.get_incremental_items(request, Some(&last_response), allow_empty_delta) + let Some(incremental_items) = + self.get_incremental_items(request, Some(&last_response), true) else { return ResponsesWsRequest::ResponseCreate(payload); }; - match ws_version { - ResponsesWebsocketVersion::V2 => { - if last_response.response_id.is_empty() { - trace!("incremental request failed, no previous response id"); - return ResponsesWsRequest::ResponseCreate(payload); - } - - ResponsesWsRequest::ResponseCreate(ResponseCreateWsRequest { - previous_response_id: Some(last_response.response_id), - input: append_items, - ..payload - }) - } - ResponsesWebsocketVersion::V1 => { - if !last_response.can_append { - trace!("incremental request failed, can't append"); - return ResponsesWsRequest::ResponseCreate(payload); - } - ResponsesWsRequest::ResponseAppend(ResponseAppendWsRequest { - input: append_items, - client_metadata: payload.client_metadata, - }) - } + if last_response.response_id.is_empty() { + trace!("incremental request failed, no previous response id"); + return ResponsesWsRequest::ResponseCreate(payload); } + + ResponsesWsRequest::ResponseCreate(ResponseCreateWsRequest { + previous_response_id: Some(last_response.response_id), + input: incremental_items, + ..payload + }) } /// Opportunistically preconnects a websocket for this turn-scoped client session. @@ -699,9 +663,9 @@ impl ModelClientSession { otel_manager: &OtelManager, model_info: &ModelInfo, ) -> std::result::Result<(), ApiError> { - let Some(ws_version) = self.client.active_ws_version(model_info) else { + if !self.client.responses_websocket_enabled(model_info) { return Ok(()); - }; + } if self.websocket_session.connection.is_some() { return Ok(()); } @@ -718,7 +682,6 @@ impl ModelClientSession { otel_manager, client_setup.api_provider, client_setup.api_auth, - ws_version, Some(Arc::clone(&self.turn_state)), None, ) @@ -732,7 +695,6 @@ impl ModelClientSession { otel_manager: &OtelManager, api_provider: codex_api::Provider, api_auth: CoreAuthProvider, - ws_version: ResponsesWebsocketVersion, turn_metadata_header: Option<&str>, options: &ApiResponsesOptions, ) -> std::result::Result<&ApiWebSocketConnection, ApiError> { @@ -754,7 +716,6 @@ impl ModelClientSession { otel_manager, api_provider, api_auth, - ws_version, Some(turn_state), turn_metadata_header, ) @@ -793,6 +754,7 @@ impl ModelClientSession { otel_manager: &OtelManager, effort: Option, summary: ReasoningSummaryConfig, + service_tier: Option, turn_metadata_header: Option<&str>, ) -> Result { if let Some(path) = &*CODEX_RS_SSE_FIXTURE { @@ -823,6 +785,7 @@ impl ModelClientSession { model_info, effort, summary, + service_tier, )?; let client = ApiResponsesClient::new( transport, @@ -854,10 +817,10 @@ impl ModelClientSession { &mut self, prompt: &Prompt, model_info: &ModelInfo, - ws_version: ResponsesWebsocketVersion, otel_manager: &OtelManager, effort: Option, summary: ReasoningSummaryConfig, + service_tier: Option, turn_metadata_header: Option<&str>, warmup: bool, ) -> Result { @@ -877,6 +840,7 @@ impl ModelClientSession { model_info, effort, summary, + service_tier, )?; let mut ws_payload = ResponseCreateWsRequest { client_metadata: build_ws_client_metadata(turn_metadata_header), @@ -891,7 +855,6 @@ impl ModelClientSession { otel_manager, client_setup.api_provider, client_setup.api_auth, - ws_version, turn_metadata_header, &options, ) @@ -912,7 +875,7 @@ impl ModelClientSession { Err(err) => return Err(map_api_error(err)), } - let ws_request = self.prepare_websocket_request(ws_payload, &request, ws_version); + let ws_request = self.prepare_websocket_request(ws_payload, &request); self.websocket_session.last_request = Some(request); let stream_result = self .websocket_session @@ -958,19 +921,13 @@ impl ModelClientSession { otel_manager: &OtelManager, effort: Option, summary: ReasoningSummaryConfig, + service_tier: Option, turn_metadata_header: Option<&str>, ) -> Result<()> { - let Some(ws_version) = self.client.active_ws_version(model_info) else { - return Ok(()); - }; - if self.websocket_session.last_request.is_some() { + if !self.client.responses_websocket_enabled(model_info) { return Ok(()); } - - if matches!(ws_version, ResponsesWebsocketVersion::V1) { - self.preconnect_websocket(otel_manager, model_info) - .await - .map_err(map_api_error)?; + if self.websocket_session.last_request.is_some() { return Ok(()); } @@ -978,10 +935,10 @@ impl ModelClientSession { .stream_responses_websocket( prompt, model_info, - ws_version, otel_manager, effort, summary, + service_tier, turn_metadata_header, true, ) @@ -1020,20 +977,21 @@ impl ModelClientSession { otel_manager: &OtelManager, effort: Option, summary: ReasoningSummaryConfig, + service_tier: Option, turn_metadata_header: Option<&str>, ) -> Result { let wire_api = self.client.state.provider.wire_api; match wire_api { WireApi::Responses => { - if let Some(ws_version) = self.client.active_ws_version(model_info) { + if self.client.responses_websocket_enabled(model_info) { match self .stream_responses_websocket( prompt, model_info, - ws_version, otel_manager, effort, summary, + service_tier, turn_metadata_header, false, ) @@ -1052,6 +1010,7 @@ impl ModelClientSession { otel_manager, effort, summary, + service_tier, turn_metadata_header, ) .await @@ -1070,7 +1029,7 @@ impl ModelClientSession { otel_manager: &OtelManager, model_info: &ModelInfo, ) -> bool { - let websocket_enabled = self.client.active_ws_version(model_info).is_some(); + let websocket_enabled = self.client.responses_websocket_enabled(model_info); let activated = self.activate_http_fallback(websocket_enabled); if activated { warn!("falling back to HTTP"); @@ -1168,7 +1127,6 @@ where Ok(ResponseEvent::Completed { response_id, token_usage, - can_append, }) => { if let Some(usage) = &token_usage { otel_manager.sse_event_completed( @@ -1183,14 +1141,12 @@ where let _ = sender.send(LastResponse { response_id: response_id.clone(), items_added: std::mem::take(&mut items_added), - can_append, }); } if tx_event .send(Ok(ResponseEvent::Completed { response_id, token_usage, - can_append, })) .await .is_err() @@ -1320,7 +1276,7 @@ mod tests { provider, session_source, None, - None, + false, false, false, None, diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index 3fea6eed56..cddf99d6f5 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -255,6 +255,7 @@ mod tests { stream: true, include: vec![], prompt_cache_key: None, + service_tier: None, text: Some(TextControls { verbosity: Some(OpenAiVerbosity::Low), format: None, @@ -296,6 +297,7 @@ mod tests { stream: true, include: vec![], prompt_cache_key: None, + service_tier: None, text: Some(text_controls), }; @@ -332,6 +334,7 @@ mod tests { stream: true, include: vec![], prompt_cache_key: None, + service_tier: None, text: None, }; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 05981029c5..3a28a3e83b 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -55,6 +55,12 @@ use async_channel::Receiver; use async_channel::Sender; use chrono::Local; use chrono::Utc; +use codex_artifact_presentation::PresentationArtifactError; +use codex_artifact_presentation::PresentationArtifactExecutionRequest; +use codex_artifact_presentation::PresentationArtifactResponse; +use codex_artifact_spreadsheet::SpreadsheetArtifactError; +use codex_artifact_spreadsheet::SpreadsheetArtifactRequest; +use codex_artifact_spreadsheet::SpreadsheetArtifactResponse; use codex_hooks::HookEvent; use codex_hooks::HookEventAfterAgent; use codex_hooks::HookPayload; @@ -290,6 +296,7 @@ use codex_otel::TelemetryAuthMode; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; +use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::ContentItem; use codex_protocol::models::DeveloperInstructions; @@ -459,6 +466,7 @@ impl Codex { provider: config.model_provider.clone(), collaboration_mode, model_reasoning_summary: config.model_reasoning_summary, + service_tier: config.service_tier, developer_instructions: config.developer_instructions.clone(), user_instructions, personality: config.personality, @@ -823,6 +831,7 @@ pub(crate) struct SessionConfiguration { collaboration_mode: CollaborationMode, model_reasoning_summary: Option, + service_tier: Option, /// Developer instructions that supplement the base instructions. developer_instructions: Option, @@ -879,6 +888,7 @@ impl SessionConfiguration { ThreadConfigSnapshot { model: self.collaboration_mode.model().to_string(), model_provider_id: self.original_config_do_not_use.model_provider_id.clone(), + service_tier: self.service_tier, approval_policy: self.approval_policy.value(), sandbox_policy: self.sandbox_policy.get().clone(), cwd: self.cwd.clone(), @@ -897,6 +907,9 @@ impl SessionConfiguration { if let Some(summary) = updates.reasoning_summary { next_configuration.model_reasoning_summary = Some(summary); } + if let Some(service_tier) = updates.service_tier { + next_configuration.service_tier = service_tier; + } if let Some(personality) = updates.personality { next_configuration.personality = Some(personality); } @@ -927,6 +940,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) windows_sandbox_level: Option, pub(crate) collaboration_mode: Option, pub(crate) reasoning_summary: Option, + pub(crate) service_tier: Option>, pub(crate) final_output_json_schema: Option>, pub(crate) personality: Option, pub(crate) app_server_client_name: Option, @@ -997,6 +1011,7 @@ impl Session { per_turn_config.model_reasoning_effort = session_configuration.collaboration_mode.reasoning_effort(); per_turn_config.model_reasoning_summary = session_configuration.model_reasoning_summary; + per_turn_config.service_tier = session_configuration.service_tier; per_turn_config.personality = session_configuration.personality; let resolved_web_search_mode = resolve_web_search_mode_for_turn( &per_turn_config.web_search_mode, @@ -1548,6 +1563,7 @@ impl Session { thread_name: session_configuration.thread_name.clone(), model: session_configuration.collaboration_mode.model().to_string(), model_provider_id: config.model_provider_id.clone(), + service_tier: session_configuration.service_tier, approval_policy: session_configuration.approval_policy.value(), sandbox_policy: session_configuration.sandbox_policy.get().clone(), cwd: session_configuration.cwd.clone(), @@ -1767,6 +1783,24 @@ impl Session { state.clear_connector_selection(); } + pub(crate) async fn execute_presentation_artifact( + &self, + request: PresentationArtifactExecutionRequest, + cwd: &Path, + ) -> Result { + let mut state = self.state.lock().await; + state.artifacts.presentation.execute_requests(request, cwd) + } + + pub(crate) async fn execute_spreadsheet_artifact( + &self, + request: SpreadsheetArtifactRequest, + cwd: &Path, + ) -> Result { + let mut state = self.state.lock().await; + state.artifacts.spreadsheet.execute(request, cwd) + } + async fn record_initial_history(&self, conversation_history: InitialHistory) { let turn_context = self.new_default_turn().await; self.clear_mcp_tool_selection().await; @@ -2367,6 +2401,8 @@ impl Session { self.send_event_raw(event).await; self.maybe_mirror_event_text_to_realtime(&legacy_source) .await; + self.maybe_clear_realtime_handoff_for_event(&legacy_source) + .await; let show_raw_agent_reasoning = self.show_raw_agent_reasoning(); for legacy in legacy_source.as_legacy_events(show_raw_agent_reasoning) { @@ -2382,14 +2418,23 @@ impl Session { let Some(text) = realtime_text_for_event(msg) else { return; }; - if self.conversation.running_state().await.is_none() { + if self.conversation.running_state().await.is_none() + || self.conversation.active_handoff_id().await.is_none() + { return; } - if let Err(err) = self.conversation.text_in(text).await { + if let Err(err) = self.conversation.handoff_out(text).await { debug!("failed to mirror event text to realtime conversation: {err}"); } } + async fn maybe_clear_realtime_handoff_for_event(&self, msg: &EventMsg) { + if !matches!(msg, EventMsg::TurnComplete(_)) { + return; + } + self.conversation.clear_active_handoff().await; + } + pub(crate) async fn send_event_raw(&self, event: Event) { // Record the last known agent status. if let Some(status) = agent_status_from_event(&event.msg) { @@ -3679,6 +3724,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv model, effort, summary, + service_tier, collaboration_mode, personality, } => { @@ -3702,6 +3748,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv windows_sandbox_level, collaboration_mode: Some(collaboration_mode), reasoning_summary: summary, + service_tier, personality, ..Default::default() }, @@ -3898,6 +3945,7 @@ mod handlers { model, effort, summary, + service_tier, final_output_json_schema, items, collaboration_mode, @@ -3922,6 +3970,7 @@ mod handlers { windows_sandbox_level: None, collaboration_mode, reasoning_summary: summary, + service_tier, final_output_json_schema: Some(final_output_json_schema), personality, app_server_client_name: None, @@ -5523,12 +5572,10 @@ async fn run_sampling_request( // transient reconnect messages. In debug builds, keep full visibility for diagnosis. let report_error = retries > 1 || cfg!(debug_assertions) - || sess + || !sess .services .model_client - .active_ws_version(&turn_context.model_info) - .is_none(); - + .responses_websocket_enabled(&turn_context.model_info); if report_error { // Surface retry information to any UI/front‑end so the // user understands what is happening instead of staring @@ -6189,6 +6236,7 @@ async fn try_run_sampling_request( &turn_context.otel_manager, turn_context.reasoning_effort, turn_context.reasoning_summary, + turn_context.config.service_tier, turn_metadata_header, ) .instrument(trace_span!("stream_request")) @@ -6368,7 +6416,6 @@ async fn try_run_sampling_request( ResponseEvent::Completed { response_id: _, token_usage, - can_append: _, } => { flush_assistant_text_segments_all( &sess, @@ -7686,6 +7733,7 @@ mod tests { model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), user_instructions: config.user_instructions.clone(), + service_tier: None, personality: config.personality, base_instructions: config .base_instructions @@ -7780,6 +7828,7 @@ mod tests { model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), user_instructions: config.user_instructions.clone(), + service_tier: None, personality: config.personality, base_instructions: config .base_instructions @@ -8093,6 +8142,7 @@ mod tests { model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), user_instructions: config.user_instructions.clone(), + service_tier: None, personality: config.personality, base_instructions: config .base_instructions @@ -8148,6 +8198,7 @@ mod tests { model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), user_instructions: config.user_instructions.clone(), + service_tier: None, personality: config.personality, base_instructions: config .base_instructions @@ -8239,6 +8290,7 @@ mod tests { model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), user_instructions: config.user_instructions.clone(), + service_tier: None, personality: config.personality, base_instructions: config .base_instructions @@ -8407,6 +8459,7 @@ mod tests { model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), user_instructions: config.user_instructions.clone(), + service_tier: None, personality: config.personality, base_instructions: config .base_instructions @@ -9138,7 +9191,7 @@ mod tests { handlers::run_user_shell_command(&session, "sub-id".to_string(), "echo shell".to_string()) .await; - let deadline = StdDuration::from_secs(5); + let deadline = StdDuration::from_secs(15); let start = std::time::Instant::now(); loop { let remaining = deadline.saturating_sub(start.elapsed()); diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index dc3c4ea753..c4fc1e0875 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -9,6 +9,7 @@ use crate::protocol::Event; use crate::protocol::Op; use crate::protocol::Submission; use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ServiceTier; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; @@ -27,6 +28,7 @@ use crate::state_db::StateDbHandle; pub struct ThreadConfigSnapshot { pub model: String, pub model_provider_id: String, + pub service_tier: Option, pub approval_policy: AskForApproval, pub sandbox_policy: SandboxPolicy, pub cwd: PathBuf, diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 11500e27cf..6c7dbb1050 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -109,7 +109,8 @@ async fn run_compact_task_inner( let max_retries = turn_context.provider.stream_max_retries(); let mut retries = 0; let mut client_session = sess.services.model_client.new_session(); - // Reuse one client session so turn-scoped state (sticky routing, websocket append tracking) + // Reuse one client session so turn-scoped state (sticky routing, websocket incremental + // request tracking) // survives retries within this compact turn. loop { @@ -402,6 +403,7 @@ async fn drain_to_completed( &turn_context.otel_manager, turn_context.reasoning_effort, turn_context.reasoning_summary, + turn_context.config.service_tier, turn_metadata_header, ) .await?; diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 3a5b1d3983..cf139d3a5e 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -5,6 +5,7 @@ use crate::path_utils::write_atomically; use anyhow::Context; use codex_config::CONFIG_TOML_FILE; use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::TrustLevel; use codex_protocol::openai_models::ReasoningEffort; use std::collections::BTreeMap; @@ -26,6 +27,8 @@ pub enum ConfigEdit { model: Option, effort: Option, }, + /// Update the service tier preference for future turns. + SetServiceTier { service_tier: Option }, /// Update the active (or default) model personality. SetModelPersonality { personality: Option }, /// Toggle the acknowledgement flag under `[notice]`. @@ -327,6 +330,10 @@ impl ConfigDocument { ); mutated }), + ConfigEdit::SetServiceTier { service_tier } => Ok(self.write_profile_value( + &["service_tier"], + service_tier.map(|service_tier| value(service_tier.to_string())), + )), ConfigEdit::SetModelPersonality { personality } => Ok(self.write_profile_value( &["personality"], personality.map(|personality| value(personality.to_string())), @@ -774,6 +781,11 @@ impl ConfigEditsBuilder { self } + pub fn set_service_tier(mut self, service_tier: Option) -> Self { + self.edits.push(ConfigEdit::SetServiceTier { service_tier }); + self + } + pub fn set_personality(mut self, personality: Option) -> Self { self.edits .push(ConfigEdit::SetModelPersonality { personality }); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index b5520a2f08..bb313f25f3 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -62,6 +62,7 @@ use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; +use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::TrustLevel; use codex_protocol::config_types::Verbosity; use codex_protocol::config_types::WebSearchMode; @@ -185,6 +186,9 @@ pub struct Config { /// Optional override of model selection. pub model: Option, + /// Effective service tier preference for new turns. + pub service_tier: Option, + /// Model used specifically for review sessions. pub review_model: Option, @@ -435,12 +439,16 @@ pub struct Config { pub realtime_audio: RealtimeAudioConfig, /// Experimental / do not use. Overrides only the realtime conversation - /// websocket transport base URL (the `Op::RealtimeConversation` `/ws` + /// websocket transport base URL (the `Op::RealtimeConversation` + /// `/v1/realtime` /// connection) without changing normal provider HTTP requests. pub experimental_realtime_ws_base_url: Option, + /// Experimental / do not use. Selects the realtime websocket model/snapshot + /// used for the `Op::RealtimeConversation` connection. + pub experimental_realtime_ws_model: Option, /// Experimental / do not use. Overrides only the realtime conversation - /// websocket transport backend prompt (the `Op::RealtimeConversation` - /// `/ws` session.create backend_prompt) without changing normal prompts. + /// websocket transport instructions (the `Op::RealtimeConversation` + /// `/ws` session.update instructions) without changing normal prompts. pub experimental_realtime_ws_backend_prompt: Option, /// When set, restricts ChatGPT login to a specific workspace identifier. pub forced_chatgpt_workspace_id: Option, @@ -1180,6 +1188,9 @@ pub struct ConfigToml { /// Optionally specify a personality for the model pub personality: Option, + /// Optional explicit service tier preference for new turns. + pub service_tier: Option, + /// Base URL for requests to ChatGPT (as opposed to the OpenAI API). pub chatgpt_base_url: Option, @@ -1188,12 +1199,16 @@ pub struct ConfigToml { pub audio: Option, /// Experimental / do not use. Overrides only the realtime conversation - /// websocket transport base URL (the `Op::RealtimeConversation` `/ws` + /// websocket transport base URL (the `Op::RealtimeConversation` + /// `/v1/realtime` /// connection) without changing normal provider HTTP requests. pub experimental_realtime_ws_base_url: Option, + /// Experimental / do not use. Selects the realtime websocket model/snapshot + /// used for the `Op::RealtimeConversation` connection. + pub experimental_realtime_ws_model: Option, /// Experimental / do not use. Overrides only the realtime conversation - /// websocket transport backend prompt (the `Op::RealtimeConversation` - /// `/ws` session.create backend_prompt) without changing normal prompts. + /// websocket transport instructions (the `Op::RealtimeConversation` + /// `/ws` session.update instructions) without changing normal prompts. pub experimental_realtime_ws_backend_prompt: Option, pub projects: Option>, @@ -1550,6 +1565,7 @@ pub struct ConfigOverrides { pub approval_policy: Option, pub sandbox_mode: Option, pub model_provider: Option, + pub service_tier: Option>, pub config_profile: Option, pub codex_linux_sandbox_exe: Option, pub main_execve_wrapper_exe: Option, @@ -1680,6 +1696,7 @@ impl Config { approval_policy: approval_policy_override, sandbox_mode, model_provider, + service_tier: service_tier_override, config_profile: config_profile_key, codex_linux_sandbox_exe, main_execve_wrapper_exe, @@ -1940,6 +1957,16 @@ impl Config { let forced_login_method = cfg.forced_login_method; let model = model.or(config_profile.model).or(cfg.model); + let service_tier = if features.enabled(Feature::FastMode) { + service_tier_override.unwrap_or_else(|| { + config_profile + .service_tier + .or(cfg.service_tier) + .filter(|tier| matches!(tier, ServiceTier::Fast)) + }) + } else { + None + }; let compact_prompt = compact_prompt.or(cfg.compact_prompt).and_then(|value| { let trimmed = value.trim(); @@ -2086,6 +2113,7 @@ impl Config { let config = Self { model, + service_tier, review_model, model_context_window: cfg.model_context_window, model_auto_compact_token_limit: cfg.model_auto_compact_token_limit, @@ -2182,6 +2210,7 @@ impl Config { speaker: audio.speaker, }), experimental_realtime_ws_base_url: cfg.experimental_realtime_ws_base_url, + experimental_realtime_ws_model: cfg.experimental_realtime_ws_model, experimental_realtime_ws_backend_prompt: cfg.experimental_realtime_ws_backend_prompt, forced_chatgpt_workspace_id, forced_login_method, @@ -4869,6 +4898,7 @@ model_verbosity = "high" review_model: None, model_context_window: None, model_auto_compact_token_limit: None, + service_tier: None, model_provider_id: "openai".to_string(), model_provider: fixture.openai_provider.clone(), permissions: Permissions { @@ -4924,6 +4954,7 @@ model_verbosity = "high" chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), realtime_audio: RealtimeAudioConfig::default(), experimental_realtime_ws_base_url: None, + experimental_realtime_ws_model: None, experimental_realtime_ws_backend_prompt: None, base_instructions: None, developer_instructions: None, @@ -4997,6 +5028,7 @@ model_verbosity = "high" review_model: None, model_context_window: None, model_auto_compact_token_limit: None, + service_tier: None, model_provider_id: "openai-custom".to_string(), model_provider: fixture.openai_custom_provider.clone(), permissions: Permissions { @@ -5052,6 +5084,7 @@ model_verbosity = "high" chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), realtime_audio: RealtimeAudioConfig::default(), experimental_realtime_ws_base_url: None, + experimental_realtime_ws_model: None, experimental_realtime_ws_backend_prompt: None, base_instructions: None, developer_instructions: None, @@ -5123,6 +5156,7 @@ model_verbosity = "high" review_model: None, model_context_window: None, model_auto_compact_token_limit: None, + service_tier: None, model_provider_id: "openai".to_string(), model_provider: fixture.openai_provider.clone(), permissions: Permissions { @@ -5178,6 +5212,7 @@ model_verbosity = "high" chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), realtime_audio: RealtimeAudioConfig::default(), experimental_realtime_ws_base_url: None, + experimental_realtime_ws_model: None, experimental_realtime_ws_backend_prompt: None, base_instructions: None, developer_instructions: None, @@ -5235,6 +5270,7 @@ model_verbosity = "high" review_model: None, model_context_window: None, model_auto_compact_token_limit: None, + service_tier: None, model_provider_id: "openai".to_string(), model_provider: fixture.openai_provider.clone(), permissions: Permissions { @@ -5290,6 +5326,7 @@ model_verbosity = "high" chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), realtime_audio: RealtimeAudioConfig::default(), experimental_realtime_ws_base_url: None, + experimental_realtime_ws_model: None, experimental_realtime_ws_backend_prompt: None, base_instructions: None, developer_instructions: None, @@ -6135,6 +6172,34 @@ experimental_realtime_ws_backend_prompt = "prompt from config" Ok(()) } + #[test] + fn experimental_realtime_ws_model_loads_from_config_toml() -> std::io::Result<()> { + let cfg: ConfigToml = toml::from_str( + r#" +experimental_realtime_ws_model = "realtime-test-model" +"#, + ) + .expect("TOML deserialization should succeed"); + + assert_eq!( + cfg.experimental_realtime_ws_model.as_deref(), + Some("realtime-test-model") + ); + + let codex_home = TempDir::new()?; + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert_eq!( + config.experimental_realtime_ws_model.as_deref(), + Some("realtime-test-model") + ); + Ok(()) + } + #[test] fn realtime_audio_loads_from_config_toml() -> std::io::Result<()> { let cfg: ConfigToml = toml::from_str( diff --git a/codex-rs/core/src/config/profile.rs b/codex-rs/core/src/config/profile.rs index 61b02adcd0..6d4cd23090 100644 --- a/codex-rs/core/src/config/profile.rs +++ b/codex-rs/core/src/config/profile.rs @@ -8,6 +8,7 @@ use crate::config::types::WindowsToml; use crate::protocol::AskForApproval; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; +use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::Verbosity; use codex_protocol::config_types::WebSearchMode; use codex_protocol::openai_models::ReasoningEffort; @@ -18,6 +19,7 @@ use codex_protocol::openai_models::ReasoningEffort; #[schemars(deny_unknown_fields)] pub struct ConfigProfile { pub model: Option, + pub service_tier: Option, /// The key in the `model_providers` map identifying the /// [`ModelProviderInfo`] to use. pub model_provider: Option, diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index 39b56ee4a8..8fd5b109d0 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -7,6 +7,7 @@ use crate::config_loader::RequirementSource; pub use codex_protocol::config_types::AltScreenMode; pub use codex_protocol::config_types::ModeKind; pub use codex_protocol::config_types::Personality; +pub use codex_protocol::config_types::ServiceTier; pub use codex_protocol::config_types::WebSearchMode; use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::BTreeMap; diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index cd8af6aa18..d76c2dfc42 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -145,6 +145,10 @@ pub enum Feature { CollaborationModes, /// Enable personality selection in the TUI. Personality, + /// Enable native artifact tools. + Artifact, + /// Enable Fast mode selection in the TUI and request layer. + FastMode, /// Enable voice transcription in the TUI composer. VoiceTranscription, /// Enable experimental realtime voice conversation mode in the TUI. @@ -660,6 +664,18 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Stable, default_enabled: true, }, + FeatureSpec { + id: Feature::Artifact, + key: "artifact", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, + FeatureSpec { + id: Feature::FastMode, + key: "fast_mode", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::VoiceTranscription, key: "voice_transcription", diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index a7bcb58f35..828bbe214a 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -116,6 +116,7 @@ pub use rollout::RolloutRecorder; pub use rollout::RolloutRecorderParams; pub use rollout::SESSIONS_SUBDIR; pub use rollout::SessionMeta; +pub use rollout::append_thread_name; pub use rollout::find_archived_thread_path_by_id_str; #[deprecated(note = "use find_thread_path_by_id_str")] pub use rollout::find_conversation_path_by_id_str; @@ -146,7 +147,6 @@ pub(crate) use codex_shell_command::powershell; pub use client::ModelClient; pub use client::ModelClientSession; -pub use client::ResponsesWebsocketVersion; pub use client::X_CODEX_TURN_METADATA_HEADER; pub use client::ws_version_from_features; pub use client_common::Prompt; diff --git a/codex-rs/core/src/mcp/skill_dependencies.rs b/codex-rs/core/src/mcp/skill_dependencies.rs index 4a1af423f7..f15bb6ec57 100644 --- a/codex-rs/core/src/mcp/skill_dependencies.rs +++ b/codex-rs/core/src/mcp/skill_dependencies.rs @@ -442,7 +442,6 @@ mod tests { dependencies: Some(SkillDependencies { tools }), policy: None, permission_profile: None, - permissions: None, path_to_skills_md: PathBuf::from("skill"), scope: SkillScope::User, } diff --git a/codex-rs/core/src/memories/phase1.rs b/codex-rs/core/src/memories/phase1.rs index fd6d4ed64b..7e1aa46136 100644 --- a/codex-rs/core/src/memories/phase1.rs +++ b/codex-rs/core/src/memories/phase1.rs @@ -13,6 +13,7 @@ use crate::rollout::policy::should_persist_response_item_for_memories; use codex_api::ResponseEvent; use codex_otel::OtelManager; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; +use codex_protocol::config_types::ServiceTier; use codex_protocol::models::BaseInstructions; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; @@ -36,6 +37,7 @@ pub(in crate::memories) struct RequestContext { pub(in crate::memories) otel_manager: OtelManager, pub(in crate::memories) reasoning_effort: Option, pub(in crate::memories) reasoning_summary: ReasoningSummaryConfig, + pub(in crate::memories) service_tier: Option, pub(in crate::memories) turn_metadata_header: Option, } @@ -144,6 +146,7 @@ impl RequestContext { otel_manager: turn_context.otel_manager.clone(), reasoning_effort: Some(phase_one::REASONING_EFFORT), reasoning_summary: turn_context.reasoning_summary, + service_tier: turn_context.config.service_tier, } } } @@ -322,6 +325,7 @@ mod job { &stage_one_context.otel_manager, stage_one_context.reasoning_effort, stage_one_context.reasoning_summary, + stage_one_context.service_tier, stage_one_context.turn_metadata_header.as_deref(), ) .await?; diff --git a/codex-rs/core/src/realtime_conversation.rs b/codex-rs/core/src/realtime_conversation.rs index 861dd31649..8842c41c96 100644 --- a/codex-rs/core/src/realtime_conversation.rs +++ b/codex-rs/core/src/realtime_conversation.rs @@ -1,5 +1,6 @@ use crate::CodexAuth; use crate::api_bridge::map_api_error; +use crate::auth::read_openai_api_key_from_env; use crate::codex::Session; use crate::default_client::default_headers; use crate::error::CodexErr; @@ -24,8 +25,10 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::RealtimeConversationClosedEvent; use codex_protocol::protocol::RealtimeConversationRealtimeEvent; use codex_protocol::protocol::RealtimeConversationStartedEvent; +use codex_protocol::protocol::RealtimeHandoffRequested; use http::HeaderMap; -use serde_json::Value; +use http::HeaderValue; +use http::header::AUTHORIZATION; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; @@ -37,17 +40,55 @@ use tracing::info; use tracing::warn; const AUDIO_IN_QUEUE_CAPACITY: usize = 256; -const TEXT_IN_QUEUE_CAPACITY: usize = 64; +const USER_TEXT_IN_QUEUE_CAPACITY: usize = 64; +const HANDOFF_OUT_QUEUE_CAPACITY: usize = 64; const OUTPUT_EVENTS_QUEUE_CAPACITY: usize = 256; pub(crate) struct RealtimeConversationManager { state: Mutex>, } +#[derive(Clone, Debug)] +struct RealtimeHandoffState { + output_tx: Sender, + active_handoff: Arc>>, +} + +#[derive(Debug, PartialEq, Eq)] +struct HandoffOutput { + handoff_id: String, + output_text: String, +} + +impl RealtimeHandoffState { + fn new(output_tx: Sender) -> Self { + Self { + output_tx, + active_handoff: Arc::new(Mutex::new(None)), + } + } + + async fn send_output(&self, output_text: String) -> CodexResult<()> { + let Some(handoff_id) = self.active_handoff.lock().await.clone() else { + return Ok(()); + }; + + self.output_tx + .send(HandoffOutput { + handoff_id, + output_text, + }) + .await + .map_err(|_| CodexErr::InvalidRequest("conversation is not running".to_string()))?; + Ok(()) + } +} + #[allow(dead_code)] struct ConversationState { audio_tx: Sender, - text_tx: Sender, + user_text_tx: Sender, + handoff: RealtimeHandoffState, task: JoinHandle<()>, realtime_active: Arc, } @@ -72,6 +113,7 @@ impl RealtimeConversationManager { api_provider: ApiProvider, extra_headers: Option, prompt: String, + model: Option, session_id: Option, ) -> CodexResult<(Receiver, Arc)> { let previous_state = { @@ -84,7 +126,11 @@ impl RealtimeConversationManager { let _ = state.task.await; } - let session_config = RealtimeSessionConfig { prompt, session_id }; + let session_config = RealtimeSessionConfig { + instructions: prompt, + model, + session_id, + }; let client = RealtimeWebsocketClient::new(api_provider); let connection = client .connect( @@ -99,17 +145,30 @@ impl RealtimeConversationManager { let events = connection.events(); let (audio_tx, audio_rx) = async_channel::bounded::(AUDIO_IN_QUEUE_CAPACITY); - let (text_tx, text_rx) = async_channel::bounded::(TEXT_IN_QUEUE_CAPACITY); + let (user_text_tx, user_text_rx) = + async_channel::bounded::(USER_TEXT_IN_QUEUE_CAPACITY); + let (handoff_output_tx, handoff_output_rx) = + async_channel::bounded::(HANDOFF_OUT_QUEUE_CAPACITY); let (events_tx, events_rx) = async_channel::bounded::(OUTPUT_EVENTS_QUEUE_CAPACITY); let realtime_active = Arc::new(AtomicBool::new(true)); - let task = spawn_realtime_input_task(writer, events, text_rx, audio_rx, events_tx); + let handoff = RealtimeHandoffState::new(handoff_output_tx); + let task = spawn_realtime_input_task( + writer, + events, + user_text_rx, + handoff_output_rx, + audio_rx, + events_tx, + handoff.clone(), + ); let mut guard = self.state.lock().await; *guard = Some(ConversationState { audio_tx, - text_tx, + user_text_tx, + handoff, task, realtime_active: Arc::clone(&realtime_active), }); @@ -143,7 +202,7 @@ impl RealtimeConversationManager { pub(crate) async fn text_in(&self, text: String) -> CodexResult<()> { let sender = { let guard = self.state.lock().await; - guard.as_ref().map(|state| state.text_tx.clone()) + guard.as_ref().map(|state| state.user_text_tx.clone()) }; let Some(sender) = sender else { @@ -159,6 +218,38 @@ impl RealtimeConversationManager { Ok(()) } + pub(crate) async fn handoff_out(&self, output_text: String) -> CodexResult<()> { + let handoff = { + let guard = self.state.lock().await; + let Some(state) = guard.as_ref() else { + return Err(CodexErr::InvalidRequest( + "conversation is not running".to_string(), + )); + }; + state.handoff.clone() + }; + + handoff.send_output(output_text).await + } + + pub(crate) async fn active_handoff_id(&self) -> Option { + let handoff = { + let guard = self.state.lock().await; + guard.as_ref().map(|state| state.handoff.clone()) + }?; + handoff.active_handoff.lock().await.clone() + } + + pub(crate) async fn clear_active_handoff(&self) { + let handoff = { + let guard = self.state.lock().await; + guard.as_ref().map(|state| state.handoff.clone()) + }; + if let Some(handoff) = handoff { + *handoff.active_handoff.lock().await = None; + } + } + pub(crate) async fn shutdown(&self) -> CodexResult<()> { let state = { let mut guard = self.state.lock().await; @@ -181,7 +272,8 @@ pub(crate) async fn handle_start( ) -> CodexResult<()> { let provider = sess.provider().await; let auth = sess.services.auth_manager.auth().await; - let mut api_provider = provider.to_api_provider(auth.as_ref().map(CodexAuth::auth_mode))?; + let realtime_api_key = realtime_api_key(auth.as_ref(), &provider)?; + let mut api_provider = provider.to_api_provider(Some(crate::auth::AuthMode::ApiKey))?; let config = sess.get_config().await; if let Some(realtime_ws_base_url) = &config.experimental_realtime_ws_base_url { api_provider.base_url = realtime_ws_base_url.clone(); @@ -190,14 +282,23 @@ pub(crate) async fn handle_start( .experimental_realtime_ws_backend_prompt .clone() .unwrap_or(params.prompt); + let model = config.experimental_realtime_ws_model.clone(); let requested_session_id = params .session_id .or_else(|| Some(sess.conversation_id.to_string())); + let extra_headers = + realtime_request_headers(requested_session_id.as_deref(), realtime_api_key.as_str())?; info!("starting realtime conversation"); let (events_rx, realtime_active) = match sess .conversation - .start(api_provider, None, prompt, requested_session_id.clone()) + .start( + api_provider, + extra_headers, + prompt, + model, + requested_session_id.clone(), + ) .await { Ok(events_rx) => events_rx, @@ -227,8 +328,8 @@ pub(crate) async fn handle_start( while let Ok(event) = events_rx.recv().await { debug!(conversation_id = %sess_clone.conversation_id, "received realtime conversation event"); let maybe_routed_text = match &event { - RealtimeEvent::ConversationItemAdded(item) => { - realtime_text_from_conversation_item(item) + RealtimeEvent::HandoffRequested(handoff) => { + realtime_text_from_handoff_request(handoff) } _ => None, }; @@ -271,26 +372,57 @@ pub(crate) async fn handle_audio( } } -fn realtime_text_from_conversation_item(item: &Value) -> Option { - match item.get("type").and_then(Value::as_str) { - Some("message") => { - if item.get("role").and_then(Value::as_str) != Some("assistant") { - return None; - } - let content = item.get("content")?.as_array()?; - let text = content - .iter() - .filter(|entry| entry.get("type").and_then(Value::as_str) == Some("text")) - .filter_map(|entry| entry.get("text").and_then(Value::as_str)) - .collect::(); - if text.is_empty() { None } else { Some(text) } - } - Some("spawn_transcript") => item - .get("delta_user_transcript") - .and_then(Value::as_str) - .and_then(|text| (!text.is_empty()).then(|| text.to_string())), - Some(_) | None => None, +fn realtime_text_from_handoff_request(handoff: &RealtimeHandoffRequested) -> Option { + (!handoff.input_transcript.is_empty()).then(|| handoff.input_transcript.clone()) +} + +fn realtime_api_key( + auth: Option<&CodexAuth>, + provider: &crate::ModelProviderInfo, +) -> CodexResult { + if let Some(api_key) = provider.api_key()? { + return Ok(api_key); } + + if let Some(token) = provider.experimental_bearer_token.clone() { + return Ok(token); + } + + if let Some(api_key) = auth.and_then(CodexAuth::api_key) { + return Ok(api_key.to_string()); + } + + // TODO(aibrahim): Remove this temporary fallback once realtime auth no longer + // requires API key auth for ChatGPT/SIWC sessions. + if provider.is_openai() + && let Some(api_key) = read_openai_api_key_from_env() + { + return Ok(api_key); + } + + Err(CodexErr::InvalidRequest( + "realtime conversation requires API key auth".to_string(), + )) +} + +fn realtime_request_headers( + session_id: Option<&str>, + api_key: &str, +) -> CodexResult> { + let mut headers = HeaderMap::new(); + + if let Some(session_id) = session_id + && let Ok(session_id) = HeaderValue::from_str(session_id) + { + headers.insert("x-session-id", session_id); + } + + let auth_value = HeaderValue::from_str(&format!("Bearer {api_key}")).map_err(|err| { + CodexErr::InvalidRequest(format!("invalid realtime api key header: {err}")) + })?; + headers.insert(AUTHORIZATION, auth_value); + + Ok(Some(headers)) } pub(crate) async fn handle_text( @@ -326,14 +458,16 @@ pub(crate) async fn handle_close(sess: &Arc, sub_id: String) { fn spawn_realtime_input_task( writer: RealtimeWebsocketWriter, events: RealtimeWebsocketEvents, - text_rx: Receiver, + user_text_rx: Receiver, + handoff_output_rx: Receiver, audio_rx: Receiver, events_tx: Sender, + handoff_state: RealtimeHandoffState, ) -> JoinHandle<()> { tokio::spawn(async move { loop { tokio::select! { - text = text_rx.recv() => { + text = user_text_rx.recv() => { match text { Ok(text) => { if let Err(err) = writer.send_conversation_item_create(text).await { @@ -345,9 +479,31 @@ fn spawn_realtime_input_task( Err(_) => break, } } + handoff_output = handoff_output_rx.recv() => { + match handoff_output { + Ok(HandoffOutput { + handoff_id, + output_text, + }) => { + if let Err(err) = writer + .send_conversation_handoff_append(handoff_id, output_text) + .await + { + let mapped_error = map_api_error(err); + warn!("failed to send handoff output: {mapped_error}"); + break; + } + } + Err(_) => break, + } + } event = events.next_event() => { match event { Ok(Some(event)) => { + if let RealtimeEvent::HandoffRequested(handoff) = &event { + *handoff_state.active_handoff.lock().await = + Some(handoff.handoff_id.clone()); + } let should_stop = matches!(&event, RealtimeEvent::Error(_)); if events_tx.send(event).await.is_err() { break; @@ -414,82 +570,98 @@ async fn send_conversation_error( #[cfg(test)] mod tests { - use super::realtime_text_from_conversation_item; + use super::HandoffOutput; + use super::RealtimeHandoffState; + use super::realtime_text_from_handoff_request; + use async_channel::bounded; + use codex_protocol::protocol::RealtimeHandoffMessage; + use codex_protocol::protocol::RealtimeHandoffRequested; use pretty_assertions::assert_eq; - use serde_json::json; #[test] - fn extracts_text_from_assistant_message_items_only() { - let assistant = json!({ - "type": "message", - "role": "assistant", - "content": [{"type": "text", "text": "hello"}], - }); + fn extracts_text_from_handoff_request_input_transcript() { + let handoff = RealtimeHandoffRequested { + handoff_id: "handoff_1".to_string(), + item_id: "item_1".to_string(), + input_transcript: "hello".to_string(), + messages: vec![RealtimeHandoffMessage { + role: "user".to_string(), + text: "hello".to_string(), + }], + }; assert_eq!( - realtime_text_from_conversation_item(&assistant), + realtime_text_from_handoff_request(&handoff), Some("hello".to_string()) ); - - let user = json!({ - "type": "message", - "role": "user", - "content": [{"type": "text", "text": "world"}], - }); - assert_eq!(realtime_text_from_conversation_item(&user), None); } #[test] - fn extracts_and_concatenates_text_entries_only() { - let item = json!({ - "type": "message", - "role": "assistant", - "content": [ - {"type": "text", "text": "a"}, - {"type": "ignored", "text": "x"}, - {"type": "text", "text": "b"} - ], - }); - assert_eq!( - realtime_text_from_conversation_item(&item), - Some("ab".to_string()) - ); + fn ignores_empty_handoff_request_input_transcript() { + let handoff = RealtimeHandoffRequested { + handoff_id: "handoff_1".to_string(), + item_id: "item_1".to_string(), + input_transcript: String::new(), + messages: vec![], + }; + assert_eq!(realtime_text_from_handoff_request(&handoff), None); } - #[test] - fn ignores_non_message_or_missing_text() { - let non_message = json!({ - "type": "tool_call", - "content": [{"type": "text", "text": "nope"}], - }); - assert_eq!(realtime_text_from_conversation_item(&non_message), None); + #[tokio::test] + async fn clears_active_handoff_explicitly() { + let (tx, _rx) = bounded(1); + let state = RealtimeHandoffState::new(tx); - let no_text = json!({ - "type": "message", - "role": "assistant", - "content": [{"type": "other", "value": 1}], - }); - assert_eq!(realtime_text_from_conversation_item(&no_text), None); - - let empty_spawn_transcript = json!({ - "type": "spawn_transcript", - "delta_user_transcript": "", - }); + *state.active_handoff.lock().await = Some("handoff_1".to_string()); assert_eq!( - realtime_text_from_conversation_item(&empty_spawn_transcript), - None + state.active_handoff.lock().await.clone(), + Some("handoff_1".to_string()) ); + + *state.active_handoff.lock().await = None; + assert_eq!(state.active_handoff.lock().await.clone(), None); } - #[test] - fn extracts_text_from_spawn_transcript_items() { - let item = json!({ - "type": "spawn_transcript", - "delta_user_transcript": "delegate from transcript", - "backend_prompt_messages": [{"role": "user", "content": "delegate from transcript"}], - }); + #[tokio::test] + async fn sends_multiple_handoff_outputs_until_cleared() { + let (tx, rx) = bounded(4); + let state = RealtimeHandoffState::new(tx); + + state + .send_output("ignored".to_string()) + .await + .expect("send"); + assert!(rx.is_empty()); + + *state.active_handoff.lock().await = Some("handoff_1".to_string()); + state.send_output("result".to_string()).await.expect("send"); + state + .send_output("result 2".to_string()) + .await + .expect("send"); + + let output_1 = rx.recv().await.expect("recv"); assert_eq!( - realtime_text_from_conversation_item(&item), - Some("delegate from transcript".to_string()) + output_1, + HandoffOutput { + handoff_id: "handoff_1".to_string(), + output_text: "result".to_string(), + } ); + + let output_2 = rx.recv().await.expect("recv"); + assert_eq!( + output_2, + HandoffOutput { + handoff_id: "handoff_1".to_string(), + output_text: "result 2".to_string(), + } + ); + + *state.active_handoff.lock().await = None; + state + .send_output("ignored after clear".to_string()) + .await + .expect("send"); + assert!(rx.is_empty()); } } diff --git a/codex-rs/core/src/rollout/metadata.rs b/codex-rs/core/src/rollout/metadata.rs index e42c67d125..0bee69b6aa 100644 --- a/codex-rs/core/src/rollout/metadata.rs +++ b/codex-rs/core/src/rollout/metadata.rs @@ -2,6 +2,7 @@ use crate::config::Config; use crate::rollout; use crate::rollout::list::parse_timestamp_uuid_from_filename; use crate::rollout::recorder::RolloutRecorder; +use crate::state_db::normalize_cwd_for_state_db; use chrono::DateTime; use chrono::NaiveDateTime; use chrono::Timelike; @@ -279,6 +280,7 @@ pub(crate) async fn backfill_sessions( ); } let mut metadata = outcome.metadata; + metadata.cwd = normalize_cwd_for_state_db(&metadata.cwd); let memory_mode = outcome.memory_mode.unwrap_or_else(|| "enabled".to_string()); if rollout.archived && metadata.archived_at.is_none() { let fallback_archived_at = metadata.updated_at; @@ -728,11 +730,62 @@ mod tests { assert!(state.last_success_at.is_some()); } + #[tokio::test] + async fn backfill_sessions_normalizes_cwd_before_upsert() { + let dir = tempdir().expect("tempdir"); + let codex_home = dir.path().to_path_buf(); + let thread_uuid = Uuid::new_v4(); + let session_cwd = codex_home.join("."); + let rollout_path = write_rollout_in_sessions_with_cwd( + codex_home.as_path(), + "2026-01-27T12-34-56", + "2026-01-27T12:34:56Z", + thread_uuid, + session_cwd.clone(), + ); + + let runtime = + codex_state::StateRuntime::init(codex_home.clone(), "test-provider".to_string(), None) + .await + .expect("initialize runtime"); + + let mut config = crate::config::test_config(); + config.codex_home = codex_home.clone(); + config.model_provider_id = "test-provider".to_string(); + backfill_sessions(runtime.as_ref(), &config, None).await; + + let thread_id = ThreadId::from_string(&thread_uuid.to_string()).expect("thread id"); + let stored = runtime + .get_thread(thread_id) + .await + .expect("get thread") + .expect("thread should be backfilled"); + + assert_eq!(stored.rollout_path, rollout_path); + assert_eq!(stored.cwd, normalize_cwd_for_state_db(&session_cwd)); + } + fn write_rollout_in_sessions( codex_home: &Path, filename_ts: &str, event_ts: &str, thread_uuid: Uuid, + ) -> PathBuf { + write_rollout_in_sessions_with_cwd( + codex_home, + filename_ts, + event_ts, + thread_uuid, + codex_home.to_path_buf(), + ) + } + + fn write_rollout_in_sessions_with_cwd( + codex_home: &Path, + filename_ts: &str, + event_ts: &str, + thread_uuid: Uuid, + cwd: PathBuf, ) -> PathBuf { let id = ThreadId::from_string(&thread_uuid.to_string()).expect("thread id"); let sessions_dir = codex_home.join("sessions"); @@ -742,7 +795,7 @@ mod tests { id, forked_from_id: None, timestamp: event_ts.to_string(), - cwd: codex_home.to_path_buf(), + cwd, originator: "cli".to_string(), cli_version: "0.0.0".to_string(), source: SessionSource::default(), diff --git a/codex-rs/core/src/rollout/mod.rs b/codex-rs/core/src/rollout/mod.rs index 5e6bd9bbff..31ee26dcaa 100644 --- a/codex-rs/core/src/rollout/mod.rs +++ b/codex-rs/core/src/rollout/mod.rs @@ -24,6 +24,7 @@ pub use list::find_thread_path_by_id_str as find_conversation_path_by_id_str; pub use list::rollout_date_parts; pub use recorder::RolloutRecorder; pub use recorder::RolloutRecorderParams; +pub use session_index::append_thread_name; pub use session_index::find_thread_name_by_id; pub use session_index::find_thread_path_by_name_str; diff --git a/codex-rs/core/src/shell_snapshot.rs b/codex-rs/core/src/shell_snapshot.rs index 7dc122e931..291129f977 100644 --- a/codex-rs/core/src/shell_snapshot.rs +++ b/codex-rs/core/src/shell_snapshot.rs @@ -360,19 +360,17 @@ alias_count=$(alias -p | wc -l | tr -d ' ') echo "# aliases $alias_count" alias -p echo '' -export_lines=$(export -p | awk ' -/^(export|declare -x|typeset -x) / { - line=$0 - name=line - sub(/^(export|declare -x|typeset -x) /, "", name) - sub(/=.*/, "", name) - if (name ~ /^(EXCLUDED_EXPORTS)$/) { - next - } - if (name ~ /^[A-Za-z_][A-Za-z0-9_]*$/) { - print line - } -}') +export_lines=$( + while IFS= read -r name; do + if [[ "$name" =~ ^(EXCLUDED_EXPORTS)$ ]]; then + continue + fi + if [[ ! "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then + continue + fi + declare -xp "$name" 2>/dev/null || true + done < <(compgen -e) +) export_count=$(printf '%s\n' "$export_lines" | sed '/^$/d' | wc -l | tr -d ' ') echo "# exports $export_count" if [ -n "$export_lines" ]; then @@ -671,6 +669,46 @@ mod tests { Ok(()) } + #[cfg(unix)] + #[test] + fn bash_snapshot_preserves_multiline_exports() -> Result<()> { + let multiline_cert = "-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----"; + let output = Command::new("/bin/bash") + .arg("-c") + .arg(bash_snapshot_script()) + .env("BASH_ENV", "/dev/null") + .env("MULTILINE_CERT", multiline_cert) + .output()?; + + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("MULTILINE_CERT=") || stdout.contains("MULTILINE_CERT"), + "snapshot should include the multiline export name" + ); + + let dir = tempdir()?; + let snapshot_path = dir.path().join("snapshot.sh"); + std::fs::write(&snapshot_path, stdout.as_bytes())?; + + let validate = Command::new("/bin/bash") + .arg("-c") + .arg("set -e; . \"$1\"") + .arg("bash") + .arg(&snapshot_path) + .env("BASH_ENV", "/dev/null") + .output()?; + + assert!( + validate.status.success(), + "snapshot validation failed: {}", + String::from_utf8_lossy(&validate.stderr) + ); + + Ok(()) + } + #[cfg(unix)] #[tokio::test] async fn try_new_creates_and_deletes_snapshot_file() -> Result<()> { diff --git a/codex-rs/core/src/skills/injection.rs b/codex-rs/core/src/skills/injection.rs index 9bb3a74781..e293aac59f 100644 --- a/codex-rs/core/src/skills/injection.rs +++ b/codex-rs/core/src/skills/injection.rs @@ -483,7 +483,6 @@ mod tests { dependencies: None, policy: None, permission_profile: None, - permissions: None, path_to_skills_md: PathBuf::from(path), scope: codex_protocol::protocol::SkillScope::User, } diff --git a/codex-rs/core/src/skills/invocation_utils.rs b/codex-rs/core/src/skills/invocation_utils.rs index d2831c4dfb..36bb0c3caa 100644 --- a/codex-rs/core/src/skills/invocation_utils.rs +++ b/codex-rs/core/src/skills/invocation_utils.rs @@ -253,7 +253,6 @@ mod tests { dependencies: None, policy: None, permission_profile: None, - permissions: None, path_to_skills_md: skill_doc_path, scope: codex_protocol::protocol::SkillScope::User, } diff --git a/codex-rs/core/src/skills/loader.rs b/codex-rs/core/src/skills/loader.rs index 110e7fa95f..bfa22cf01b 100644 --- a/codex-rs/core/src/skills/loader.rs +++ b/codex-rs/core/src/skills/loader.rs @@ -1,4 +1,3 @@ -use crate::config::Permissions; use crate::config_loader::ConfigLayerStack; use crate::config_loader::ConfigLayerStackOrdering; use crate::config_loader::default_project_root_markers; @@ -12,7 +11,6 @@ use crate::skills::model::SkillLoadOutcome; use crate::skills::model::SkillMetadata; use crate::skills::model::SkillPolicy; use crate::skills::model::SkillToolDependency; -use crate::skills::permissions::compile_permission_profile; use crate::skills::system::system_cache_root_dir; use codex_app_server_protocol::ConfigLayerSource; use codex_protocol::models::PermissionProfile; @@ -69,7 +67,6 @@ struct LoadedSkillMetadata { dependencies: Option, policy: Option, permission_profile: Option, - permissions: Option, } #[derive(Debug, Default, Deserialize)] @@ -528,7 +525,6 @@ fn parse_skill_file(path: &Path, scope: SkillScope) -> Result Result LoadedSkillMetadata { policy, permissions, } = parsed; - let permission_profile = permissions.clone().filter(|profile| !profile.is_empty()); - LoadedSkillMetadata { interface: resolve_interface(interface, skill_dir), dependencies: resolve_dependencies(dependencies), policy: resolve_policy(policy), - permission_profile, - permissions: compile_permission_profile(skill_dir, permissions), + permission_profile: permissions.filter(|profile| !profile.is_empty()), } } @@ -864,9 +856,7 @@ mod tests { use crate::config::ConfigBuilder; use crate::config::ConfigOverrides; use crate::config::ConfigToml; - use crate::config::Constrained; use crate::config::ProjectConfig; - use crate::config::types::ShellEnvironmentPolicy; use crate::config_loader::ConfigLayerEntry; use crate::config_loader::ConfigLayerStack; use crate::config_loader::ConfigRequirements; @@ -1100,7 +1090,6 @@ mod tests { dependencies: None, policy: None, permission_profile: None, - permissions: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, }] @@ -1258,7 +1247,6 @@ mod tests { }), policy: None, permission_profile: None, - permissions: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, }] @@ -1315,7 +1303,6 @@ interface: dependencies: None, policy: None, permission_profile: None, - permissions: None, path_to_skills_md: normalized(skill_path.as_path()), scope: SkillScope::User, }] @@ -1435,44 +1422,6 @@ permissions: macos: None, }) ); - #[cfg(target_os = "macos")] - let macos_seatbelt_profile_extensions = - Some(crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions::default()); - #[cfg(not(target_os = "macos"))] - let macos_seatbelt_profile_extensions = None; - assert_eq!( - outcome.skills[0].permissions, - Some(Permissions { - approval_policy: Constrained::allow_any(crate::protocol::AskForApproval::Never), - sandbox_policy: Constrained::allow_any( - crate::protocol::SandboxPolicy::WorkspaceWrite { - writable_roots: vec![ - AbsolutePathBuf::try_from(normalized( - skill_dir.join("output").as_path(), - )) - .expect("absolute output path") - ], - read_only_access: crate::protocol::ReadOnlyAccess::Restricted { - include_platform_defaults: true, - readable_roots: vec![ - AbsolutePathBuf::try_from(normalized( - skill_dir.join("data").as_path(), - )) - .expect("absolute data path") - ], - }, - network_access: true, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - } - ), - network: None, - allow_login_shell: true, - shell_environment_policy: ShellEnvironmentPolicy::default(), - windows_sandbox_mode: None, - macos_seatbelt_profile_extensions, - }) - ); } #[tokio::test] @@ -1497,34 +1446,7 @@ permissions: {} outcome.errors ); assert_eq!(outcome.skills.len(), 1); - #[cfg(target_os = "macos")] - let expected = Some(Permissions { - approval_policy: Constrained::allow_any(crate::protocol::AskForApproval::Never), - sandbox_policy: Constrained::allow_any( - crate::protocol::SandboxPolicy::new_read_only_policy(), - ), - network: None, - allow_login_shell: true, - shell_environment_policy: ShellEnvironmentPolicy::default(), - windows_sandbox_mode: None, - macos_seatbelt_profile_extensions: Some( - crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions::default(), - ), - }); - #[cfg(not(target_os = "macos"))] - let expected = Some(Permissions { - approval_policy: Constrained::allow_any(crate::protocol::AskForApproval::Never), - sandbox_policy: Constrained::allow_any( - crate::protocol::SandboxPolicy::new_read_only_policy(), - ), - network: None, - allow_login_shell: true, - shell_environment_policy: ShellEnvironmentPolicy::default(), - windows_sandbox_mode: None, - macos_seatbelt_profile_extensions: None, - }); assert_eq!(outcome.skills[0].permission_profile, None); - assert_eq!(outcome.skills[0].permissions, expected); } #[cfg(target_os = "macos")] @@ -1556,24 +1478,21 @@ permissions: outcome.errors ); assert_eq!(outcome.skills.len(), 1); - let profile = outcome.skills[0] - .permissions - .as_ref() - .expect("permission profile"); assert_eq!( - profile.macos_seatbelt_profile_extensions, - Some( - crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions { - macos_preferences: - crate::seatbelt_permissions::MacOsPreferencesPermission::ReadWrite, - macos_automation: - crate::seatbelt_permissions::MacOsAutomationPermission::BundleIds(vec![ - "com.apple.Notes".to_string() - ],), - macos_accessibility: true, - macos_calendar: true, - } - ) + outcome.skills[0].permission_profile, + Some(PermissionProfile { + macos: Some(codex_protocol::models::MacOsPermissions { + preferences: Some(codex_protocol::models::MacOsPreferencesValue::Mode( + "readwrite".to_string(), + ),), + automations: Some(codex_protocol::models::MacOsAutomationValue::BundleIds( + vec!["com.apple.Notes".to_string()], + )), + accessibility: Some(true), + calendar: Some(true), + }), + ..Default::default() + }) ); } @@ -1607,17 +1526,19 @@ permissions: ); assert_eq!(outcome.skills.len(), 1); assert_eq!( - outcome.skills[0].permissions, - Some(Permissions { - approval_policy: Constrained::allow_any(crate::protocol::AskForApproval::Never), - sandbox_policy: Constrained::allow_any( - crate::protocol::SandboxPolicy::new_read_only_policy(), - ), - network: None, - allow_login_shell: true, - shell_environment_policy: ShellEnvironmentPolicy::default(), - windows_sandbox_mode: None, - macos_seatbelt_profile_extensions: None, + outcome.skills[0].permission_profile, + Some(PermissionProfile { + macos: Some(codex_protocol::models::MacOsPermissions { + preferences: Some(codex_protocol::models::MacOsPreferencesValue::Mode( + "readwrite".to_string(), + )), + automations: Some(codex_protocol::models::MacOsAutomationValue::BundleIds( + vec!["com.apple.Notes".to_string()], + )), + accessibility: Some(true), + calendar: Some(true), + }), + ..Default::default() }) ); } @@ -1667,7 +1588,6 @@ permissions: dependencies: None, policy: None, permission_profile: None, - permissions: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, }] @@ -1709,7 +1629,6 @@ permissions: dependencies: None, policy: None, permission_profile: None, - permissions: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, }] @@ -1764,7 +1683,6 @@ permissions: dependencies: None, policy: None, permission_profile: None, - permissions: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, }] @@ -1807,7 +1725,6 @@ permissions: dependencies: None, policy: None, permission_profile: None, - permissions: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, }] @@ -1853,7 +1770,6 @@ permissions: dependencies: None, policy: None, permission_profile: None, - permissions: None, path_to_skills_md: normalized(&shared_skill_path), scope: SkillScope::User, }] @@ -1915,7 +1831,6 @@ permissions: dependencies: None, policy: None, permission_profile: None, - permissions: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, }] @@ -1953,7 +1868,6 @@ permissions: dependencies: None, policy: None, permission_profile: None, - permissions: None, path_to_skills_md: normalized(&shared_skill_path), scope: SkillScope::Admin, }] @@ -1995,7 +1909,6 @@ permissions: dependencies: None, policy: None, permission_profile: None, - permissions: None, path_to_skills_md: normalized(&linked_skill_path), scope: SkillScope::Repo, }] @@ -2064,7 +1977,6 @@ permissions: dependencies: None, policy: None, permission_profile: None, - permissions: None, path_to_skills_md: normalized(&within_depth_path), scope: SkillScope::User, }] @@ -2093,7 +2005,6 @@ permissions: dependencies: None, policy: None, permission_profile: None, - permissions: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, }] @@ -2127,7 +2038,6 @@ permissions: dependencies: None, policy: None, permission_profile: None, - permissions: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, }] @@ -2170,7 +2080,6 @@ permissions: dependencies: None, policy: None, permission_profile: None, - permissions: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, }] @@ -2203,7 +2112,6 @@ permissions: dependencies: None, policy: None, permission_profile: None, - permissions: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, }] @@ -2317,7 +2225,6 @@ permissions: dependencies: None, policy: None, permission_profile: None, - permissions: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::Repo, }] @@ -2354,7 +2261,6 @@ permissions: dependencies: None, policy: None, permission_profile: None, - permissions: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::Repo, }] @@ -2409,7 +2315,6 @@ permissions: dependencies: None, policy: None, permission_profile: None, - permissions: None, path_to_skills_md: normalized(&nested_skill_path), scope: SkillScope::Repo, }, @@ -2421,7 +2326,6 @@ permissions: dependencies: None, policy: None, permission_profile: None, - permissions: None, path_to_skills_md: normalized(&root_skill_path), scope: SkillScope::Repo, }, @@ -2462,7 +2366,6 @@ permissions: dependencies: None, policy: None, permission_profile: None, - permissions: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::Repo, }] @@ -2501,7 +2404,6 @@ permissions: dependencies: None, policy: None, permission_profile: None, - permissions: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::Repo, }] @@ -2544,7 +2446,6 @@ permissions: dependencies: None, policy: None, permission_profile: None, - permissions: None, path_to_skills_md: normalized(&repo_skill_path), scope: SkillScope::Repo, }, @@ -2556,7 +2457,6 @@ permissions: dependencies: None, policy: None, permission_profile: None, - permissions: None, path_to_skills_md: normalized(&user_skill_path), scope: SkillScope::User, }, @@ -2622,7 +2522,6 @@ permissions: dependencies: None, policy: None, permission_profile: None, - permissions: None, path_to_skills_md: first_path, scope: SkillScope::Repo, }, @@ -2634,7 +2533,6 @@ permissions: dependencies: None, policy: None, permission_profile: None, - permissions: None, path_to_skills_md: second_path, scope: SkillScope::Repo, }, @@ -2707,7 +2605,6 @@ permissions: dependencies: None, policy: None, permission_profile: None, - permissions: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::Repo, }] @@ -2767,7 +2664,6 @@ permissions: dependencies: None, policy: None, permission_profile: None, - permissions: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::System, }] diff --git a/codex-rs/core/src/skills/model.rs b/codex-rs/core/src/skills/model.rs index ddb1dd2638..525ea28da3 100644 --- a/codex-rs/core/src/skills/model.rs +++ b/codex-rs/core/src/skills/model.rs @@ -3,7 +3,6 @@ use std::collections::HashSet; use std::path::PathBuf; use std::sync::Arc; -use crate::config::Permissions; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::SkillScope; @@ -16,8 +15,6 @@ pub struct SkillMetadata { pub dependencies: Option, pub policy: Option, pub permission_profile: Option, - // This is an experimental field. - pub permissions: Option, /// Path to the SKILLS.md file that declares this skill. pub path_to_skills_md: PathBuf, pub scope: SkillScope, diff --git a/codex-rs/core/src/skills/permissions.rs b/codex-rs/core/src/skills/permissions.rs index 38d02ee0b6..1d0e6c82ad 100644 --- a/codex-rs/core/src/skills/permissions.rs +++ b/codex-rs/core/src/skills/permissions.rs @@ -1,26 +1,42 @@ +#[cfg(any(unix, test))] use std::collections::HashSet; -use std::path::Path; #[cfg(target_os = "macos")] use codex_protocol::models::MacOsAutomationValue; +#[cfg(any(unix, test))] use codex_protocol::models::MacOsPermissions; #[cfg(target_os = "macos")] use codex_protocol::models::MacOsPreferencesValue; +#[cfg(any(unix, test))] use codex_protocol::models::MacOsSeatbeltProfileExtensions; +#[cfg(any(unix, test))] use codex_protocol::models::PermissionProfile; +#[cfg(any(unix, test))] use codex_utils_absolute_path::AbsolutePathBuf; +#[cfg(any(unix, test))] use dunce::canonicalize as canonicalize_path; +#[cfg(any(unix, test))] use tracing::warn; +#[cfg(any(unix, test))] use crate::config::Constrained; +#[cfg(any(unix, test))] use crate::config::Permissions; +#[cfg(any(unix, test))] use crate::config::types::ShellEnvironmentPolicy; +#[cfg(any(unix, test))] use crate::protocol::AskForApproval; +#[cfg(any(unix, test))] use crate::protocol::ReadOnlyAccess; +#[cfg(any(unix, test))] use crate::protocol::SandboxPolicy; +/// Compiles a skill `PermissionProfile` for the Unix shell escalation path. +/// +/// Normal Windows builds do not currently call this helper, so it is only +/// compiled on Unix and in tests. +#[cfg(any(unix, test))] pub(crate) fn compile_permission_profile( - _skill_dir: &Path, permissions: Option, ) -> Option { let PermissionProfile { @@ -78,6 +94,7 @@ pub(crate) fn compile_permission_profile( }) } +#[cfg(any(unix, test))] fn normalize_permission_paths(values: &[AbsolutePathBuf], field: &str) -> Vec { let mut paths = Vec::new(); let mut seen = HashSet::new(); @@ -94,6 +111,7 @@ fn normalize_permission_paths(values: &[AbsolutePathBuf], field: &str) -> Vec Option { let canonicalized = canonicalize_path(value.as_path()).unwrap_or_else(|_| value.to_path_buf()); match AbsolutePathBuf::from_absolute_path(&canonicalized) { @@ -184,7 +202,7 @@ fn resolve_macos_automation_permission( } } -#[cfg(not(target_os = "macos"))] +#[cfg(all(not(target_os = "macos"), any(unix, test)))] fn build_macos_seatbelt_profile_extensions( _: &MacOsPermissions, ) -> Option { @@ -225,21 +243,18 @@ mod tests { let read_dir = skill_dir.join("data"); fs::create_dir_all(&read_dir).expect("read dir"); - let profile = compile_permission_profile( - &skill_dir, - Some(PermissionProfile { - network: Some(true), - file_system: Some(FileSystemPermissions { - read: Some(vec![ - absolute_path(&skill_dir.join("data")), - absolute_path(&skill_dir.join("data")), - absolute_path(&skill_dir.join("scripts/../data")), - ]), - write: Some(vec![absolute_path(&skill_dir.join("output"))]), - }), - ..Default::default() + let profile = compile_permission_profile(Some(PermissionProfile { + network: Some(true), + file_system: Some(FileSystemPermissions { + read: Some(vec![ + absolute_path(&skill_dir.join("data")), + absolute_path(&skill_dir.join("data")), + absolute_path(&skill_dir.join("scripts/../data")), + ]), + write: Some(vec![absolute_path(&skill_dir.join("output"))]), }), - ) + ..Default::default() + })) .expect("profile"); assert_eq!( @@ -284,7 +299,7 @@ mod tests { let skill_dir = tempdir.path().join("skill"); fs::create_dir_all(&skill_dir).expect("skill dir"); - let profile = compile_permission_profile(&skill_dir, None); + let profile = compile_permission_profile(None); assert_eq!(profile, None); } @@ -295,13 +310,10 @@ mod tests { let skill_dir = tempdir.path().join("skill"); fs::create_dir_all(&skill_dir).expect("skill dir"); - let profile = compile_permission_profile( - &skill_dir, - Some(PermissionProfile { - network: Some(true), - ..Default::default() - }), - ) + let profile = compile_permission_profile(Some(PermissionProfile { + network: Some(true), + ..Default::default() + })) .expect("profile"); assert_eq!( @@ -330,17 +342,14 @@ mod tests { let read_dir = skill_dir.join("data"); fs::create_dir_all(&read_dir).expect("read dir"); - let profile = compile_permission_profile( - &skill_dir, - Some(PermissionProfile { - network: Some(true), - file_system: Some(FileSystemPermissions { - read: Some(vec![absolute_path(&skill_dir.join("data"))]), - write: Some(Vec::new()), - }), - ..Default::default() + let profile = compile_permission_profile(Some(PermissionProfile { + network: Some(true), + file_system: Some(FileSystemPermissions { + read: Some(vec![absolute_path(&skill_dir.join("data"))]), + write: Some(Vec::new()), }), - ) + ..Default::default() + })) .expect("profile"); assert_eq!( @@ -379,20 +388,17 @@ mod tests { let skill_dir = tempdir.path().join("skill"); fs::create_dir_all(&skill_dir).expect("skill dir"); - let profile = compile_permission_profile( - &skill_dir, - Some(PermissionProfile { - macos: Some(MacOsPermissions { - preferences: Some(MacOsPreferencesValue::Mode("readwrite".to_string())), - automations: Some(MacOsAutomationValue::BundleIds(vec![ - "com.apple.Notes".to_string(), - ])), - accessibility: Some(true), - calendar: Some(true), - }), - ..Default::default() + let profile = compile_permission_profile(Some(PermissionProfile { + macos: Some(MacOsPermissions { + preferences: Some(MacOsPreferencesValue::Mode("readwrite".to_string())), + automations: Some(MacOsAutomationValue::BundleIds(vec![ + "com.apple.Notes".to_string(), + ])), + accessibility: Some(true), + calendar: Some(true), }), - ) + ..Default::default() + })) .expect("profile"); assert_eq!( @@ -419,8 +425,8 @@ mod tests { let skill_dir = tempdir.path().join("skill"); fs::create_dir_all(&skill_dir).expect("skill dir"); - let profile = compile_permission_profile(&skill_dir, Some(PermissionProfile::default())) - .expect("profile"); + let profile = + compile_permission_profile(Some(PermissionProfile::default())).expect("profile"); assert_eq!( profile.macos_seatbelt_profile_extensions, diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 576db7cf3c..babfb044d8 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -1,5 +1,7 @@ //! Session-wide mutable state. +use codex_artifact_presentation::PresentationArtifactManager; +use codex_artifact_spreadsheet::SpreadsheetArtifactManager; use codex_protocol::models::ResponseItem; use std::collections::HashMap; use std::collections::HashSet; @@ -16,6 +18,12 @@ use crate::tasks::RegularTask; use crate::truncate::TruncationPolicy; use codex_protocol::protocol::TurnContextItem; +#[derive(Default)] +pub(crate) struct SessionArtifacts { + pub(crate) presentation: PresentationArtifactManager, + pub(crate) spreadsheet: SpreadsheetArtifactManager, +} + /// Persistent, session-scoped state previously stored directly on `Session`. pub(crate) struct SessionState { pub(crate) session_configuration: SessionConfiguration, @@ -32,6 +40,7 @@ pub(crate) struct SessionState { pub(crate) startup_regular_task: Option>>, pub(crate) active_mcp_tool_selection: Option>, pub(crate) active_connector_selection: HashSet, + pub(crate) artifacts: SessionArtifacts, } impl SessionState { @@ -49,6 +58,7 @@ impl SessionState { startup_regular_task: None, active_mcp_tool_selection: None, active_connector_selection: HashSet::new(), + artifacts: SessionArtifacts::default(), } } diff --git a/codex-rs/core/src/state_db.rs b/codex-rs/core/src/state_db.rs index fb73961f87..43641402fe 100644 --- a/codex-rs/core/src/state_db.rs +++ b/codex-rs/core/src/state_db.rs @@ -157,7 +157,7 @@ fn cursor_to_anchor(cursor: Option<&Cursor>) -> Option { Some(codex_state::Anchor { ts, id }) } -fn normalize_cwd_for_state_db(cwd: &Path) -> PathBuf { +pub(crate) fn normalize_cwd_for_state_db(cwd: &Path) -> PathBuf { normalize_for_path_comparison(cwd).unwrap_or_else(|_| cwd.to_path_buf()) } diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index 725039d677..62493a46cd 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -44,6 +44,7 @@ impl RegularTask { &turn_context.otel_manager, turn_context.reasoning_effort, turn_context.reasoning_summary, + turn_context.config.service_tier, turn_metadata_header.as_deref(), ) .await?; diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index 89e35fa227..908f3d18d0 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -8,10 +8,12 @@ mod mcp; mod mcp_resource; pub(crate) mod multi_agents; mod plan; +mod presentation_artifact; mod read_file; mod request_user_input; mod search_tool_bm25; mod shell; +mod spreadsheet_artifact; mod test_sync; pub(crate) mod unified_exec; mod view_image; @@ -38,6 +40,7 @@ pub use mcp::McpHandler; pub use mcp_resource::McpResourceHandler; pub use multi_agents::MultiAgentHandler; pub use plan::PlanHandler; +pub use presentation_artifact::PresentationArtifactHandler; pub use read_file::ReadFileHandler; pub use request_user_input::RequestUserInputHandler; pub(crate) use request_user_input::request_user_input_tool_description; @@ -46,6 +49,7 @@ pub(crate) use search_tool_bm25::SEARCH_TOOL_BM25_TOOL_NAME; pub use search_tool_bm25::SearchToolBm25Handler; pub use shell::ShellCommandHandler; pub use shell::ShellHandler; +pub use spreadsheet_artifact::SpreadsheetArtifactHandler; pub use test_sync::TestSyncHandler; pub use unified_exec::UnifiedExecHandler; pub use view_image::ViewImageHandler; diff --git a/codex-rs/core/src/tools/handlers/multi_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents.rs index 076a496a49..c179fcef7a 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents.rs @@ -1,3 +1,10 @@ +//! Implements the collaboration tool surface for spawning and managing sub-agents. +//! +//! This handler translates model tool calls into `AgentControl` operations and keeps spawned +//! agents aligned with the live turn that created them. Sub-agents start from the turn's effective +//! config, inherit runtime-only state such as provider, approval policy, sandbox, and cwd, and +//! then optionally layer role-specific config on top. + use crate::agent::AgentStatus; use crate::agent::exceeds_thread_spawn_depth_limit; use crate::codex::Session; @@ -35,6 +42,7 @@ use serde::Deserialize; use serde::Serialize; use std::collections::HashMap; +/// Function-tool handler for the multi-agent collaboration API. pub struct MultiAgentHandler; /// Minimum wait timeout to prevent tight polling loops from burning CPU. @@ -894,6 +902,13 @@ fn input_preview(items: &[UserInput]) -> String { parts.join("\n") } +/// Builds the base config snapshot for a newly spawned sub-agent. +/// +/// The returned config starts from the parent's effective config and then refreshes the +/// runtime-owned fields carried on `turn`, including model selection, reasoning settings, +/// approval policy, sandbox, and cwd. Role-specific overrides are layered after this step; +/// skipping this helper and cloning stale config state directly can send the child agent out with +/// the wrong provider or runtime policy. pub(crate) fn build_agent_spawn_config( base_instructions: &BaseInstructions, turn: &TurnContext, @@ -928,6 +943,10 @@ fn build_agent_shared_config(turn: &TurnContext) -> Result ToolKind { + ToolKind::Function + } + + async fn is_mutating(&self, invocation: &ToolInvocation) -> bool { + let ToolPayload::Function { arguments } = &invocation.payload else { + return true; + }; + let Ok(request) = parse_arguments::(arguments) else { + return true; + }; + request.is_mutating().unwrap_or(true) + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + payload, + call_id, + .. + } = invocation; + + if !session.enabled(Feature::Artifact) { + return Err(FunctionCallError::RespondToModel( + "presentation_artifact is disabled by feature flag".to_string(), + )); + } + + let arguments = match payload { + ToolPayload::Function { arguments } => arguments, + _ => { + return Err(FunctionCallError::RespondToModel( + "presentation_artifact handler received unsupported payload".to_string(), + )); + } + }; + + let request: PresentationArtifactToolRequest = parse_arguments(&arguments)?; + for access in request + .required_path_accesses(&turn.cwd) + .map_err(presentation_error)? + { + authorize_path_access(session.as_ref(), turn.as_ref(), &call_id, &access).await?; + } + + let response = session + .execute_presentation_artifact( + request + .into_execution_request() + .map_err(presentation_error)?, + &turn.cwd, + ) + .await + .map_err(presentation_error)?; + + Ok(ToolOutput::Function { + body: FunctionCallOutputBody::Text(to_string(&response).map_err(|error| { + FunctionCallError::RespondToModel(format!( + "failed to serialize presentation_artifact response: {error}" + )) + })?), + success: Some(true), + }) + } +} + +fn presentation_error(error: PresentationArtifactError) -> FunctionCallError { + FunctionCallError::RespondToModel(error.to_string()) +} + +async fn authorize_path_access( + session: &Session, + turn: &TurnContext, + call_id: &str, + access: &PathAccessRequirement, +) -> Result<(), FunctionCallError> { + let effective_path = match access.kind { + PathAccessKind::Read => effective_read_path(&access.path), + PathAccessKind::Write => effective_write_path(&access.path), + }; + let allowed = match access.kind { + PathAccessKind::Read => path_is_readable(turn, &effective_path), + PathAccessKind::Write => path_is_writable(turn, &effective_path), + }; + if allowed { + return Ok(()); + } + + let approval_policy = turn.approval_policy.value(); + if !matches!( + approval_policy, + AskForApproval::OnRequest | AskForApproval::UnlessTrusted + ) { + return Err(FunctionCallError::RespondToModel(format!( + "{} path `{}` is outside the current sandbox policy", + access_kind_label(access.kind), + access.path.display() + ))); + } + + let key = format!( + "presentation_artifact:{:?}:{}", + access.kind, + effective_path.display() + ); + let path = access.path.clone(); + let action = access.action.clone(); + let decision = with_cached_approval( + &session.services, + "presentation_artifact", + vec![key], + || { + let path = path.clone(); + let action = action.clone(); + async move { + session + .request_command_approval( + turn, + call_id.to_string(), + None, + vec![ + "presentation_artifact".to_string(), + action, + path.display().to_string(), + ], + turn.cwd.clone(), + Some(format!( + "Allow presentation_artifact to {} `{}`?", + access_kind_verb(access.kind), + path.display() + )), + None, + None, + None, + None, + ) + .await + } + }, + ) + .await; + + if matches!( + decision, + ReviewDecision::Approved + | ReviewDecision::ApprovedForSession + | ReviewDecision::ApprovedExecpolicyAmendment { .. } + ) { + return Ok(()); + } + + Err(FunctionCallError::RespondToModel(format!( + "{} path `{}` was not approved", + access_kind_label(access.kind), + access.path.display() + ))) +} + +fn path_is_readable(turn: &TurnContext, path: &Path) -> bool { + if turn.sandbox_policy.has_full_disk_read_access() { + return true; + } + + turn.sandbox_policy + .get_readable_roots_with_cwd(&turn.cwd) + .iter() + .any(|root| path.starts_with(root.as_path())) +} + +fn path_is_writable(turn: &TurnContext, path: &Path) -> bool { + if turn.sandbox_policy.has_full_disk_write_access() { + return true; + } + + turn.sandbox_policy + .get_writable_roots_with_cwd(&turn.cwd) + .iter() + .any(|root| root.is_path_writable(path)) +} + +fn effective_read_path(path: &Path) -> PathBuf { + normalize_for_path_comparison(path).unwrap_or_else(|_| normalize_without_fs(path)) +} + +fn effective_write_path(path: &Path) -> PathBuf { + let write_path = resolve_symlink_write_paths(path) + .map(|paths| paths.write_path) + .unwrap_or_else(|_| path.to_path_buf()); + normalize_for_path_comparison(&write_path).unwrap_or_else(|_| normalize_without_fs(&write_path)) +} + +fn normalize_without_fs(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::ParentDir => { + normalized.pop(); + } + Component::CurDir => {} + other => normalized.push(other.as_os_str()), + } + } + normalized +} + +fn access_kind_label(kind: PathAccessKind) -> &'static str { + match kind { + PathAccessKind::Read => "read", + PathAccessKind::Write => "write", + } +} + +fn access_kind_verb(kind: PathAccessKind) -> &'static str { + match kind { + PathAccessKind::Read => "read from", + PathAccessKind::Write => "write to", + } +} diff --git a/codex-rs/core/src/tools/handlers/spreadsheet_artifact.rs b/codex-rs/core/src/tools/handlers/spreadsheet_artifact.rs new file mode 100644 index 0000000000..be4eb7fc58 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/spreadsheet_artifact.rs @@ -0,0 +1,235 @@ +use async_trait::async_trait; +use codex_artifact_spreadsheet::PathAccessKind; +use codex_artifact_spreadsheet::PathAccessRequirement; +use codex_artifact_spreadsheet::SpreadsheetArtifactError; +use codex_artifact_spreadsheet::SpreadsheetArtifactRequest; +use codex_protocol::models::FunctionCallOutputBody; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::ReviewDecision; +use serde_json::to_string; +use std::path::Component; +use std::path::Path; +use std::path::PathBuf; + +use crate::codex::Session; +use crate::codex::TurnContext; +use crate::features::Feature; +use crate::function_tool::FunctionCallError; +use crate::path_utils::normalize_for_path_comparison; +use crate::path_utils::resolve_symlink_write_paths; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolOutput; +use crate::tools::context::ToolPayload; +use crate::tools::handlers::parse_arguments; +use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolKind; +use crate::tools::sandboxing::with_cached_approval; + +pub struct SpreadsheetArtifactHandler; + +#[async_trait] +impl ToolHandler for SpreadsheetArtifactHandler { + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + async fn is_mutating(&self, _invocation: &ToolInvocation) -> bool { + true + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + payload, + call_id, + .. + } = invocation; + + if !session.enabled(Feature::Artifact) { + return Err(FunctionCallError::RespondToModel( + "spreadsheet_artifact is disabled by feature flag".to_string(), + )); + } + + let arguments = match payload { + ToolPayload::Function { arguments } => arguments, + _ => { + return Err(FunctionCallError::RespondToModel( + "spreadsheet_artifact handler received unsupported payload".to_string(), + )); + } + }; + + let request: SpreadsheetArtifactRequest = parse_arguments(&arguments)?; + for access in request + .required_path_accesses(&turn.cwd) + .map_err(spreadsheet_error)? + { + authorize_path_access(session.as_ref(), turn.as_ref(), &call_id, &access).await?; + } + + let response = session + .execute_spreadsheet_artifact(request, &turn.cwd) + .await + .map_err(spreadsheet_error)?; + + Ok(ToolOutput::Function { + body: FunctionCallOutputBody::Text(to_string(&response).map_err(|error| { + FunctionCallError::RespondToModel(format!( + "failed to serialize spreadsheet_artifact response: {error}" + )) + })?), + success: Some(true), + }) + } +} + +fn spreadsheet_error(error: SpreadsheetArtifactError) -> FunctionCallError { + FunctionCallError::RespondToModel(error.to_string()) +} + +async fn authorize_path_access( + session: &Session, + turn: &TurnContext, + call_id: &str, + access: &PathAccessRequirement, +) -> Result<(), FunctionCallError> { + let effective_path = match access.kind { + PathAccessKind::Read => effective_read_path(&access.path), + PathAccessKind::Write => effective_write_path(&access.path), + }; + let allowed = match access.kind { + PathAccessKind::Read => path_is_readable(turn, &effective_path), + PathAccessKind::Write => path_is_writable(turn, &effective_path), + }; + if allowed { + return Ok(()); + } + + let approval_policy = turn.approval_policy.value(); + if !matches!( + approval_policy, + AskForApproval::OnRequest | AskForApproval::UnlessTrusted + ) { + return Err(FunctionCallError::RespondToModel(format!( + "{} path `{}` is outside the current sandbox policy", + access_kind_label(access.kind), + access.path.display() + ))); + } + + let key = format!( + "spreadsheet_artifact:{:?}:{}", + access.kind, + effective_path.display() + ); + let path = access.path.clone(); + let action = access.action.clone(); + let decision = + with_cached_approval(&session.services, "spreadsheet_artifact", vec![key], || { + let path = path.clone(); + let action = action.clone(); + async move { + session + .request_command_approval( + turn, + call_id.to_string(), + None, + vec![ + "spreadsheet_artifact".to_string(), + action, + path.display().to_string(), + ], + turn.cwd.clone(), + Some(format!( + "Allow spreadsheet_artifact to {} `{}`?", + access_kind_verb(access.kind), + path.display() + )), + None, + None, + None, + None, + ) + .await + } + }) + .await; + + if matches!( + decision, + ReviewDecision::Approved + | ReviewDecision::ApprovedForSession + | ReviewDecision::ApprovedExecpolicyAmendment { .. } + ) { + return Ok(()); + } + + Err(FunctionCallError::RespondToModel(format!( + "{} path `{}` was not approved", + access_kind_label(access.kind), + access.path.display() + ))) +} + +fn path_is_readable(turn: &TurnContext, path: &Path) -> bool { + if turn.sandbox_policy.has_full_disk_read_access() { + return true; + } + + turn.sandbox_policy + .get_readable_roots_with_cwd(&turn.cwd) + .iter() + .any(|root| path.starts_with(root.as_path())) +} + +fn path_is_writable(turn: &TurnContext, path: &Path) -> bool { + if turn.sandbox_policy.has_full_disk_write_access() { + return true; + } + + turn.sandbox_policy + .get_writable_roots_with_cwd(&turn.cwd) + .iter() + .any(|root| root.is_path_writable(path)) +} + +fn effective_read_path(path: &Path) -> PathBuf { + normalize_for_path_comparison(path).unwrap_or_else(|_| normalize_without_fs(path)) +} + +fn effective_write_path(path: &Path) -> PathBuf { + let write_path = resolve_symlink_write_paths(path) + .map(|paths| paths.write_path) + .unwrap_or_else(|_| path.to_path_buf()); + normalize_for_path_comparison(&write_path).unwrap_or_else(|_| normalize_without_fs(&write_path)) +} + +fn normalize_without_fs(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::ParentDir => { + normalized.pop(); + } + Component::CurDir => {} + other => normalized.push(other.as_os_str()), + } + } + normalized +} + +fn access_kind_label(kind: PathAccessKind) -> &'static str { + match kind { + PathAccessKind::Read => "read", + PathAccessKind::Write => "write", + } +} + +fn access_kind_verb(kind: PathAccessKind) -> &'static str { + match kind { + PathAccessKind::Read => "read from", + PathAccessKind::Write => "write to", + } +} diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index 5288fd8e82..36f63be4fa 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -9,6 +9,7 @@ use crate::features::Feature; use crate::sandboxing::SandboxPermissions; use crate::shell::ShellType; use crate::skills::SkillMetadata; +use crate::skills::permissions::compile_permission_profile; use crate::tools::runtimes::ExecveSessionApproval; use crate::tools::runtimes::build_command_spec; use crate::tools::sandboxing::SandboxAttempt; @@ -227,9 +228,7 @@ impl CoreShellActionProvider { } fn skill_escalation_execution(skill: &SkillMetadata) -> EscalationExecution { - skill - .permissions - .as_ref() + compile_permission_profile(skill.permission_profile.clone()) .map(|permissions| { EscalationExecution::Permissions(EscalationPermissions::Permissions( EscalatedPermissions { @@ -240,13 +239,6 @@ impl CoreShellActionProvider { }, )) }) - .or_else(|| { - skill - .permission_profile - .clone() - .map(EscalationPermissions::PermissionProfile) - .map(EscalationExecution::Permissions) - }) .unwrap_or(EscalationExecution::TurnDefault) } diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 76730d0890..0e53670750 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -55,6 +55,7 @@ pub(crate) struct ToolsConfig { pub js_repl_enabled: bool, pub js_repl_tools_only: bool, pub collab_tools: bool, + pub artifact_tools: bool, pub default_mode_request_user_input: bool, pub experimental_supported_tools: Vec, pub agent_jobs_tools: bool, @@ -84,6 +85,7 @@ impl ToolsConfig { let include_default_mode_request_user_input = features.enabled(Feature::DefaultModeRequestUserInput); let include_search_tool = features.enabled(Feature::Apps); + let include_artifact_tools = features.enabled(Feature::Artifact); let include_agent_jobs = include_collab_tools && features.enabled(Feature::Sqlite); let request_permission_enabled = features.enabled(Feature::RequestPermissions); let shell_command_backend = @@ -139,6 +141,7 @@ impl ToolsConfig { js_repl_enabled: include_js_repl, js_repl_tools_only: include_js_repl_tools_only, collab_tools: include_collab_tools, + artifact_tools: include_artifact_tools, default_mode_request_user_input: include_default_mode_request_user_input, experimental_supported_tools: model_info.experimental_supported_tools.clone(), agent_jobs_tools: include_agent_jobs, @@ -561,6 +564,97 @@ fn create_view_image_tool() -> ToolSpec { }) } +fn create_presentation_artifact_tool() -> ToolSpec { + let action_step_schema = JsonSchema::Object { + properties: BTreeMap::from([ + ( + "action".to_string(), + JsonSchema::String { + description: Some("Action name to run for this step.".to_string()), + }, + ), + ( + "args".to_string(), + JsonSchema::Object { + properties: BTreeMap::new(), + required: None, + additional_properties: Some(true.into()), + }, + ), + ]), + required: Some(vec!["action".to_string(), "args".to_string()]), + additional_properties: Some(false.into()), + }; + let properties = BTreeMap::from([ + ( + "artifact_id".to_string(), + JsonSchema::String { + description: Some( + "Artifact id returned by an earlier presentation_artifact call.".to_string(), + ), + }, + ), + ( + "actions".to_string(), + JsonSchema::Array { + items: Box::new(action_step_schema), + description: Some( + "Array of `(action, args)` steps to execute sequentially.".to_string(), + ), + }, + ), + ]); + + ToolSpec::Function(ResponsesApiTool { + name: "presentation_artifact".to_string(), + description: "Create or edit a presentation artifact for the current thread.".to_string(), + strict: false, + parameters: JsonSchema::Object { + properties, + required: Some(vec!["actions".to_string()]), + additional_properties: Some(false.into()), + }, + }) +} + +fn create_spreadsheet_artifact_tool() -> ToolSpec { + let properties = BTreeMap::from([ + ( + "artifact_id".to_string(), + JsonSchema::String { + description: Some( + "Artifact id returned by an earlier spreadsheet_artifact call.".to_string(), + ), + }, + ), + ( + "action".to_string(), + JsonSchema::String { + description: Some("Action name to run for this request.".to_string()), + }, + ), + ( + "args".to_string(), + JsonSchema::Object { + properties: BTreeMap::new(), + required: None, + additional_properties: Some(true.into()), + }, + ), + ]); + + ToolSpec::Function(ResponsesApiTool { + name: "spreadsheet_artifact".to_string(), + description: "Create or edit a spreadsheet artifact for the current thread.".to_string(), + strict: false, + parameters: JsonSchema::Object { + properties, + required: Some(vec!["action".to_string(), "args".to_string()]), + additional_properties: Some(false.into()), + }, + }) +} + fn create_collab_input_items_schema() -> JsonSchema { let properties = BTreeMap::from([ ( @@ -1658,11 +1752,13 @@ pub(crate) fn build_specs( use crate::tools::handlers::McpResourceHandler; use crate::tools::handlers::MultiAgentHandler; use crate::tools::handlers::PlanHandler; + use crate::tools::handlers::PresentationArtifactHandler; use crate::tools::handlers::ReadFileHandler; use crate::tools::handlers::RequestUserInputHandler; use crate::tools::handlers::SearchToolBm25Handler; use crate::tools::handlers::ShellCommandHandler; use crate::tools::handlers::ShellHandler; + use crate::tools::handlers::SpreadsheetArtifactHandler; use crate::tools::handlers::TestSyncHandler; use crate::tools::handlers::UnifiedExecHandler; use crate::tools::handlers::ViewImageHandler; @@ -1685,6 +1781,8 @@ pub(crate) fn build_specs( let search_tool_handler = Arc::new(SearchToolBm25Handler); let js_repl_handler = Arc::new(JsReplHandler); let js_repl_reset_handler = Arc::new(JsReplResetHandler); + let presentation_artifact_handler = Arc::new(PresentationArtifactHandler); + let spreadsheet_artifact_handler = Arc::new(SpreadsheetArtifactHandler); let request_permission_enabled = config.request_permission_enabled; match &config.shell_type { @@ -1822,6 +1920,13 @@ pub(crate) fn build_specs( builder.push_spec_with_parallel_support(create_view_image_tool(), true); builder.register_handler("view_image", view_image_handler); + if config.artifact_tools { + builder.push_spec(create_presentation_artifact_tool()); + builder.push_spec(create_spreadsheet_artifact_tool()); + builder.register_handler("presentation_artifact", presentation_artifact_handler); + builder.register_handler("spreadsheet_artifact", spreadsheet_artifact_handler); + } + if config.collab_tools { let multi_agent_handler = Arc::new(MultiAgentHandler); builder.push_spec(create_spawn_agent_tool(config)); @@ -2122,6 +2227,23 @@ mod tests { ); } + #[test] + fn test_build_specs_artifact_tool_enabled() { + let config = test_config(); + let model_info = + ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let mut features = Features::with_defaults(); + features.enable(Feature::Artifact); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + assert_contains_tool_names(&tools, &["presentation_artifact", "spreadsheet_artifact"]); + } + #[test] fn test_build_specs_agent_job_worker_tools_enabled() { let config = test_config(); diff --git a/codex-rs/core/templates/collaboration_mode/plan.md b/codex-rs/core/templates/collaboration_mode/plan.md index 5c58310ae4..8a1a934f83 100644 --- a/codex-rs/core/templates/collaboration_mode/plan.md +++ b/codex-rs/core/templates/collaboration_mode/plan.md @@ -107,7 +107,7 @@ Example: plan content -plan content should be human and agent digestible. The final plan must be plan-only and include: +plan content should be human and agent digestible. The final plan must be plan-only, concise by default, and include: * A clear title * A brief summary section @@ -115,6 +115,12 @@ plan content should be human and agent digestible. The final plan must be plan-o * Test cases and scenarios * Explicit assumptions and defaults chosen where needed +When possible, prefer a compact structure with 3-5 short sections, usually: Summary, Key Changes or Implementation Changes, Test Plan, and Assumptions. Do not include a separate Scope section unless scope boundaries are genuinely important to avoid mistakes. + +Prefer grouped implementation bullets by subsystem or behavior over file-by-file inventories. Mention files only when needed to disambiguate a non-obvious change, and avoid naming more than 3 paths unless extra specificity is necessary to prevent mistakes. Prefer behavior-level descriptions over symbol-by-symbol removal lists. For v1 feature-addition plans, do not invent detailed schema, validation, precedence, fallback, or wire-shape policy unless the request establishes it or it is needed to prevent a concrete implementation mistake; prefer the intended capability and minimum interface/behavior changes. + +Keep bullets short and avoid explanatory sub-bullets unless they are needed to prevent ambiguity. Prefer the minimum detail needed for implementation safety, not exhaustive coverage. Within each section, compress related changes into a few high-signal bullets and omit branch-by-branch logic, repeated invariants, and long lists of unaffected behavior unless they are necessary to prevent a likely implementation mistake. Avoid repeated repo facts and irrelevant edge-case or rollout detail. For straightforward refactors, keep the plan to a compact summary, key edits, tests, and assumptions. If the user asks for more detail, then expand. + Do not ask "should I proceed?" in the final output. The user can easily switch out of Plan mode and request implementation if you have included a `` block in your response. Alternatively, they can decide to stay in Plan mode and continue refining the plan. Only produce at most one `` block per turn, and only when you are presenting a complete spec. diff --git a/codex-rs/core/templates/tools/presentation_artifact.md b/codex-rs/core/templates/tools/presentation_artifact.md new file mode 100644 index 0000000000..45a91bc43b --- /dev/null +++ b/codex-rs/core/templates/tools/presentation_artifact.md @@ -0,0 +1,200 @@ +Create and edit PowerPoint presentation artifacts inside the current thread. + +- This is a stateful built-in tool. `artifact_id` values are returned by earlier calls and persist only for the current thread. +- Resume and fork do not restore live artifact state. Export files if you need a durable handoff. +- Relative paths resolve from the current working directory. +- Position and size values are in slide points. +- Every tool call uses a top-level `actions` array of sequential steps. Each call operates on a single top-level `artifact_id` when one is needed. If a call starts with `create` or `import_pptx`, later steps in the same call automatically reuse the returned artifact id. + +Supported actions: +- `create` +- `import_pptx` +- `export_pptx` +- `export_preview` +- `get_summary` +- `list_slides` +- `list_layouts` +- `list_layout_placeholders` +- `list_slide_placeholders` +- `inspect` +- `resolve` +- `to_proto` +- `record_patch` +- `apply_patch` +- `undo` +- `redo` +- `create_layout` +- `add_layout_placeholder` +- `set_slide_layout` +- `update_placeholder_text` +- `set_theme` +- `add_style` +- `get_style` +- `describe_styles` +- `set_notes` +- `set_notes_rich_text` +- `append_notes` +- `clear_notes` +- `set_notes_visibility` +- `set_active_slide` +- `add_slide` +- `insert_slide` +- `duplicate_slide` +- `move_slide` +- `delete_slide` +- `set_slide_background` +- `add_text_shape` +- `add_shape` +- `add_connector` +- `add_image` +- `replace_image` +- `add_table` +- `update_table_style` +- `style_table_block` +- `update_table_cell` +- `merge_table_cells` +- `add_chart` +- `update_chart` +- `add_chart_series` +- `update_text` +- `set_rich_text` +- `format_text_range` +- `replace_text` +- `insert_text_after` +- `set_hyperlink` +- `set_comment_author` +- `add_comment_thread` +- `add_comment_reply` +- `toggle_comment_reaction` +- `resolve_comment_thread` +- `reopen_comment_thread` +- `update_shape_style` +- `bring_to_front` +- `send_to_back` +- `delete_element` +- `delete_artifact` + +Example create: +`{"actions":[{"action":"create","args":{"name":"Quarterly Update"}}]}` + +Example create with custom slide size: +`{"actions":[{"action":"create","args":{"name":"Quarterly Update","slide_size":{"width":960,"height":540}}}]}` + +Example edit: +`{"artifact_id":"presentation_x","actions":[{"action":"add_text_shape","args":{"slide_index":0,"text":"Revenue up 24%","position":{"left":48,"top":72,"width":260,"height":80}}}]}` + +Example sequential batch: +`{"actions":[{"action":"create","args":{"name":"Quarterly Update"}},{"action":"add_slide","args":{}},{"action":"add_text_shape","args":{"slide_index":0,"text":"Revenue up 24%","position":{"left":48,"top":72,"width":260,"height":80}}}]}` + +Table creation also accepts optional `column_widths` and `row_heights` arrays in points when you need explicit table sizing instead of even splits. Tables also support `style_options`, `borders`, and `right_to_left`, with `update_table_style` and `style_table_block` available for incremental styling after creation. + +Example export: +`{"artifact_id":"presentation_x","actions":[{"action":"export_pptx","args":{"path":"artifacts/q2-update.pptx"}}]}` + +Example layout flow: +`{"artifact_id":"presentation_x","actions":[{"action":"create_layout","args":{"name":"Title Slide"}}]}` + +`{"artifact_id":"presentation_x","actions":[{"action":"add_layout_placeholder","args":{"layout_id":"layout_1","name":"title","placeholder_type":"title","text":"Click to add title","position":{"left":48,"top":48,"width":624,"height":72}}}]}` + +`{"artifact_id":"presentation_x","actions":[{"action":"set_slide_layout","args":{"slide_index":0,"layout_id":"layout_1"}}]}` + +`{"artifact_id":"presentation_x","actions":[{"action":"list_layout_placeholders","args":{"layout_id":"layout_1"}}]}` + +`{"artifact_id":"presentation_x","actions":[{"action":"list_slide_placeholders","args":{"slide_index":0}}]}` + +Layout references in `create_layout.parent_layout_id`, `add_layout_placeholder.layout_id`, `add_slide`, `insert_slide`, `set_slide_layout`, and `list_layout_placeholders` accept either a layout id or a layout name. Name matching prefers exact id, then exact name, then case-insensitive name. + +`insert_slide` accepts `index` or `after_slide_index`. If neither is provided, the new slide is inserted immediately after the active slide, or appended if no active slide is set yet. + +Example inspect: +`{"artifact_id":"presentation_x","actions":[{"action":"inspect","args":{"include":"deck,slide,textbox,shape,table,chart,image,notes,layoutList,textRange,comment","exclude":"notes","search":"roadmap","max_chars":12000}}]}` + +Example inspect target window: +`{"artifact_id":"presentation_x","actions":[{"action":"inspect","args":{"include":"textbox","target":{"id":"sh/element_3","before_lines":1,"after_lines":1}}}]}` + +Example resolve: +`{"artifact_id":"presentation_x","actions":[{"action":"resolve","args":{"id":"sh/element_3"}}]}` + +Example proto export: +`{"artifact_id":"presentation_x","actions":[{"action":"to_proto","args":{}}]}` + +`to_proto` returns a full JSON snapshot of the current in-memory presentation document, including slide/layout records, anchors, notes, theme state, and typed element payloads. + +Rich text is supported on notes, text boxes, shapes with text, and table cells. Use `set_rich_text` to replace a full rich-text payload, `set_notes_rich_text` for speaker notes, and `format_text_range` to annotate a substring by `query` or explicit codepoint range. `inspect`, `resolve`, and `to_proto` surface text-range anchors as `tr/`. + +Comment threads are supported through `set_comment_author`, `add_comment_thread`, `add_comment_reply`, `toggle_comment_reaction`, `resolve_comment_thread`, and `reopen_comment_thread`. Thread anchors resolve as `th/`, and comment records appear in both `inspect` and `to_proto`. + +Charts support richer series metadata plus `update_chart` and `add_chart_series`, including legend, axis, data-label, marker, fill, and per-point override state. + +Exported PPTX files embed Codex metadata so rich text, comment threads, and advanced table/chart state round-trip through `export_pptx` and `import_pptx` even when the base OOXML representation is lossy. + +Example patch recording: +`{"artifact_id":"presentation_x","actions":[{"action":"record_patch","args":{"operations":[{"action":"add_text_shape","args":{"slide_index":0,"text":"Headline","position":{"left":48,"top":48,"width":320,"height":72}}},{"action":"set_slide_background","args":{"slide_index":0,"fill":"#F7F1E8"}}]}}]}` + +Example patch application: +`{"artifact_id":"presentation_x","actions":[{"action":"apply_patch","args":{"patch":{"version":1,"artifactId":"presentation_x","operations":[{"action":"add_text_shape","args":{"slide_index":0,"text":"Headline","position":{"left":48,"top":48,"width":320,"height":72}}},{"action":"set_slide_background","args":{"slide_index":0,"fill":"#F7F1E8"}}]}}}]}` + +Patch payloads are single-artifact and currently support existing in-memory editing actions like slide/element/layout/theme/text updates. Lifecycle, import/export, and nested history actions are intentionally excluded. + +Example undo/redo: +`{"artifact_id":"presentation_x","actions":[{"action":"undo","args":{}}]}` + +`{"artifact_id":"presentation_x","actions":[{"action":"redo","args":{}}]}` + +Deck summaries, slide listings, `inspect`, and `resolve` now include active-slide metadata. Use `set_active_slide` to change it explicitly. + +Theme snapshots and `to_proto` both expose the deck theme hex color map via `hex_color_map` / `hexColorMap`. + +Named text styles are supported through `add_style`, `get_style`, and `describe_styles`. Built-in styles include `title`, `heading1`, `body`, `list`, and `numberedList`. + +Example style creation: +`{"artifact_id":"presentation_x","actions":[{"action":"add_style","args":{"name":"callout","font_size":18,"color":"#336699","italic":true,"underline":true}}]}` + +Example style lookup: +`{"artifact_id":"presentation_x","actions":[{"action":"get_style","args":{"name":"title"}}]}` + +Text styling payloads on `add_text_shape`, `add_shape.text_style`, `update_text.styling`, and `update_table_cell.styling` accept `style` and `underline` in addition to the existing whole-element fields. + +Text-bearing elements also support literal `replace_text` and `insert_text_after` helpers for in-place edits without resending the full string. + +Example rich text update: +`{"artifact_id":"presentation_x","actions":[{"action":"set_rich_text","args":{"element_id":"element_3","text":[[{"run":"Quarterly ","text_style":{"bold":true}},"update pipeline"]],"text_layout":{"wrap":"square","auto_fit":"shrinkText","vertical_alignment":"middle","insets":{"left":6,"right":6,"top":4,"bottom":4}}}}]}` + +Example substring formatting: +`{"artifact_id":"presentation_x","actions":[{"action":"format_text_range","args":{"element_id":"element_3","query":"update","styling":{"italic":true},"link":{"uri":"https://example.com/update","is_external":true}}}]}` + +Text boxes and shapes support whole-element hyperlinks via `set_hyperlink`. Supported `link_type` values are `url`, `slide`, `first_slide`, `last_slide`, `next_slide`, `previous_slide`, `end_show`, `email`, and `file`. Use `clear: true` to remove an existing hyperlink. + +Notes visibility is honored on export: `set_notes_visibility` controls whether speaker notes are emitted into exported PPTX output. + +Example comment thread: +`{"artifact_id":"presentation_x","actions":[{"action":"set_comment_author","args":{"display_name":"Jamie Fox","initials":"JF"}},{"action":"add_comment_thread","args":{"slide_index":0,"element_id":"element_3","query":"Quarterly","text":"Tighten this headline"}},{"action":"add_comment_reply","args":{"thread_id":"thread_1","text":"Applied to the draft."}}]}` + +Image placeholders can be prompt-only. `add_image` accepts `prompt` without `path`/`data_url`, and unresolved placeholders export as a visible placeholder box instead of failing. + +Layout placeholders with `placeholder_type: "picture"` or `"image"` materialize as placeholder image elements on slides, so they appear in `list_slide_placeholders`, `inspect`, and `resolve` as `image` records rather than generic shapes. + +Remote images are supported. `add_image` and `replace_image` accept `uri` in addition to local `path`, raw base64 `blob`, and `data_url`. + +Image edits can target inspect/resolve anchors like `im/element_3`, and `update_shape_style` now accepts image `fit`, `crop`, `rotation`, `flip_horizontal`, `flip_vertical`, and `lock_aspect_ratio` updates. + +`add_image` and `replace_image` also accept optional `rotation`, `flip_horizontal`, and `flip_vertical` fields for image transforms. + +`update_shape_style.position` accepts partial updates, so you can move or resize an element without resending the full rect. + +Shape strokes accept an optional `style` field such as `solid`, `dashed`, `dotted`, `dash-dot`, `dash-dot-dot`, `long-dash`, or `long-dash-dot`. This applies to ordinary shapes via `add_shape.stroke` and `update_shape_style.stroke`. + +`add_shape` also accepts optional `rotation`, `flip_horizontal`, and `flip_vertical` fields. Those same transform fields can also be provided inside the `position` object for `add_shape`, `add_image`, and `update_shape_style`. + +Connectors are supported via `add_connector`, with straight/elbow/curved types plus dash styles and arrow heads. + +Example chart update: +`{"artifact_id":"presentation_x","actions":[{"action":"update_chart","args":{"element_id":"element_7","style_index":12,"legend_position":"bottom","y_axis_title":"USD (millions)"}},{"action":"add_chart_series","args":{"element_id":"element_7","name":"Target","values":[11,13],"fill":"#70AD47","marker":{"symbol":"diamond","size":6}}}]}` + +Example preview: +`{"artifact_id":"presentation_x","actions":[{"action":"export_preview","args":{"slide_index":0,"path":"artifacts/q2-update-slide1.png"}}]}` + +`export_preview` also accepts `format`, `scale`, and `quality` for rendered previews. `format` currently supports `png`, `jpeg`, and `svg`. + +Example JPEG preview: +`{"artifact_id":"presentation_x","actions":[{"action":"export_preview","args":{"slide_index":0,"path":"artifacts/q2-update-slide1.jpg","format":"jpeg","scale":0.75,"quality":85}}]}` diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index bb6de200a9..4f2e89636e 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -390,10 +390,15 @@ impl WebSocketRequest { #[derive(Debug, Clone)] pub struct WebSocketHandshake { + uri: String, headers: Vec<(String, String)>, } impl WebSocketHandshake { + pub fn uri(&self) -> &str { + &self.uri + } + pub fn header(&self, name: &str) -> Option { self.headers .iter() @@ -577,25 +582,6 @@ pub fn ev_completed(id: &str) -> Value { }) } -pub fn ev_done() -> Value { - serde_json::json!({ - "type": "response.done", - "response": { - "usage": {"input_tokens":0,"input_tokens_details":null,"output_tokens":0,"output_tokens_details":null,"total_tokens":0} - } - }) -} - -pub fn ev_done_with_id(id: &str) -> Value { - serde_json::json!({ - "type": "response.done", - "response": { - "id": id, - "usage": {"input_tokens":0,"input_tokens_details":null,"output_tokens":0,"output_tokens_details":null,"total_tokens":0} - } - }) -} - /// Convenience: SSE event for a created response with a specific id. pub fn ev_response_created(id: &str) -> Value { serde_json::json!({ @@ -1223,10 +1209,10 @@ pub async fn start_websocket_server_with_headers( .map(|value| (name.as_str().to_string(), value.to_string())) }) .collect(); - handshake_log - .lock() - .unwrap() - .push(WebSocketHandshake { headers }); + handshake_log.lock().unwrap().push(WebSocketHandshake { + uri: req.uri().to_string(), + headers, + }); let headers_mut = response.headers_mut(); for (name, value) in &response_headers { diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index bd15c6d7e4..392d4d354f 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -11,6 +11,7 @@ use codex_core::ThreadManager; use codex_core::built_in_model_providers; use codex_core::config::Config; use codex_core::features::Feature; +use codex_protocol::config_types::ServiceTier; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; @@ -126,6 +127,7 @@ impl TestCodexBuilder { let base_url_clone = base_url.clone(); self.config_mutators.push(Box::new(move |config| { config.model_provider.base_url = Some(base_url_clone); + config.experimental_realtime_ws_model = Some("realtime-test-model".to_string()); config.features.enable(Feature::ResponsesWebsockets); })); self.build_with_home_and_base_url(base_url, home, None) @@ -281,11 +283,36 @@ impl TestCodex { .await } + pub async fn submit_turn_with_service_tier( + &self, + prompt: &str, + service_tier: Option, + ) -> Result<()> { + self.submit_turn_with_context( + prompt, + AskForApproval::Never, + SandboxPolicy::DangerFullAccess, + Some(service_tier), + ) + .await + } + pub async fn submit_turn_with_policies( &self, prompt: &str, approval_policy: AskForApproval, sandbox_policy: SandboxPolicy, + ) -> Result<()> { + self.submit_turn_with_context(prompt, approval_policy, sandbox_policy, None) + .await + } + + async fn submit_turn_with_context( + &self, + prompt: &str, + approval_policy: AskForApproval, + sandbox_policy: SandboxPolicy, + service_tier: Option>, ) -> Result<()> { let session_model = self.session_configured.model.clone(); self.codex @@ -301,6 +328,7 @@ impl TestCodex { model: session_model, effort: None, summary: None, + service_tier, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index f502ec08be..be41a25a0f 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -6,7 +6,6 @@ use codex_core::ModelClient; use codex_core::ModelProviderInfo; use codex_core::Prompt; use codex_core::ResponseEvent; -use codex_core::ResponsesWebsocketVersion; use codex_core::WireApi; use codex_otel::OtelManager; use codex_otel::TelemetryAuthMode; @@ -92,7 +91,7 @@ async fn responses_stream_includes_subagent_header_on_review() { provider.clone(), session_source, config.model_verbosity, - None::, + false, false, false, None, @@ -118,6 +117,7 @@ async fn responses_stream_includes_subagent_header_on_review() { effort, summary.unwrap_or(model_info.default_reasoning_summary), None, + None, ) .await .expect("stream failed"); @@ -204,7 +204,7 @@ async fn responses_stream_includes_subagent_header_on_other() { provider.clone(), session_source, config.model_verbosity, - None::, + false, false, false, None, @@ -230,6 +230,7 @@ async fn responses_stream_includes_subagent_header_on_other() { effort, summary.unwrap_or(model_info.default_reasoning_summary), None, + None, ) .await .expect("stream failed"); @@ -315,7 +316,7 @@ async fn responses_respects_model_info_overrides_from_config() { provider.clone(), session_source, config.model_verbosity, - None::, + false, false, false, None, @@ -341,6 +342,7 @@ async fn responses_respects_model_info_overrides_from_config() { effort, summary.unwrap_or(model_info.default_reasoning_summary), None, + None, ) .await .expect("stream failed"); diff --git a/codex-rs/core/tests/suite/agent_websocket.rs b/codex-rs/core/tests/suite/agent_websocket.rs index 26b07bbd52..0db3cf61ad 100644 --- a/codex-rs/core/tests/suite/agent_websocket.rs +++ b/codex-rs/core/tests/suite/agent_websocket.rs @@ -1,10 +1,9 @@ use anyhow::Result; use codex_core::features::Feature; +use codex_protocol::config_types::ServiceTier; use core_test_support::responses::WebSocketConnectionConfig; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; -use core_test_support::responses::ev_done; -use core_test_support::responses::ev_done_with_id; use core_test_support::responses::ev_response_created; use core_test_support::responses::ev_shell_command_call; use core_test_support::responses::start_websocket_server; @@ -26,7 +25,7 @@ async fn websocket_test_codex_shell_chain() -> Result<()> { vec![ ev_response_created("resp-1"), ev_shell_command_call(call_id, "echo websocket"), - ev_done(), + ev_completed("resp-1"), ], vec![ ev_response_created("resp-2"), @@ -58,36 +57,30 @@ async fn websocket_test_codex_shell_chain() -> Result<()> { .body_json(); assert_eq!(first_turn["type"].as_str(), Some("response.create")); - assert_eq!(second_turn["type"].as_str(), Some("response.append")); + assert_eq!(second_turn["type"].as_str(), Some("response.create")); - let append_items = second_turn + let input_items = second_turn .get("input") .and_then(Value::as_array) - .expect("response.append input array"); - assert!(!append_items.is_empty()); - - let output_item = append_items - .iter() - .find(|item| item.get("type").and_then(Value::as_str) == Some("function_call_output")) - .expect("function_call_output in append"); - assert_eq!( - output_item.get("call_id").and_then(Value::as_str), - Some(call_id) - ); + .expect("second response.create input array"); + assert!(!input_items.is_empty()); server.shutdown().await; Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn websocket_first_turn_uses_preconnect_and_create() -> Result<()> { +async fn websocket_first_turn_uses_startup_prewarm_and_create() -> Result<()> { skip_if_no_network!(Ok(())); - let server = start_websocket_server(vec![vec![vec![ - ev_response_created("resp-1"), - ev_assistant_message("msg-1", "hello"), - ev_completed("resp-1"), - ]]]) + let server = start_websocket_server(vec![vec![ + vec![ev_response_created("warm-1"), ev_completed("warm-1")], + vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "hello"), + ev_completed("resp-1"), + ], + ]]) .await; let mut builder = test_codex(); @@ -100,11 +93,14 @@ async fn websocket_first_turn_uses_preconnect_and_create() -> Result<()> { assert_eq!(server.handshakes().len(), 1); let connection = server.single_connection(); - assert_eq!(connection.len(), 1); - let turn = connection + assert_eq!(connection.len(), 2); + let warmup = connection .first() - .expect("missing turn request") + .expect("missing warmup request") .body_json(); + let turn = connection.get(1).expect("missing turn request").body_json(); + assert_eq!(warmup["type"].as_str(), Some("response.create")); + assert_eq!(warmup["generate"].as_bool(), Some(false)); assert!( turn["tools"] .as_array() @@ -118,15 +114,18 @@ async fn websocket_first_turn_uses_preconnect_and_create() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn websocket_first_turn_handles_handshake_delay_with_preconnect() -> Result<()> { +async fn websocket_first_turn_handles_handshake_delay_with_startup_prewarm() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_websocket_server_with_headers(vec![WebSocketConnectionConfig { - requests: vec![vec![ - ev_response_created("resp-1"), - ev_assistant_message("msg-1", "hello"), - ev_completed("resp-1"), - ]], + requests: vec![ + vec![ev_response_created("warm-1"), ev_completed("warm-1")], + vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "hello"), + ev_completed("resp-1"), + ], + ], response_headers: Vec::new(), // Delay handshake so turn processing must tolerate websocket startup latency. accept_delay: Some(Duration::from_millis(150)), @@ -143,11 +142,14 @@ async fn websocket_first_turn_handles_handshake_delay_with_preconnect() -> Resul assert_eq!(server.handshakes().len(), 1); let connection = server.single_connection(); - assert_eq!(connection.len(), 1); - let turn = connection + assert_eq!(connection.len(), 2); + let warmup = connection .first() - .expect("missing turn request") + .expect("missing warmup request") .body_json(); + let turn = connection.get(1).expect("missing turn request").body_json(); + assert_eq!(warmup["type"].as_str(), Some("response.create")); + assert_eq!(warmup["generate"].as_bool(), Some(false)); assert!( turn["tools"] .as_array() @@ -166,7 +168,7 @@ async fn websocket_v2_test_codex_shell_chain() -> Result<()> { let call_id = "shell-command-call"; let server = start_websocket_server(vec![vec![ - vec![ev_response_created("warm-1"), ev_done_with_id("warm-1")], + vec![ev_response_created("warm-1"), ev_completed("warm-1")], vec![ ev_response_created("resp-1"), ev_shell_command_call(call_id, "echo websocket"), @@ -244,3 +246,171 @@ async fn websocket_v2_test_codex_shell_chain() -> Result<()> { server.shutdown().await; Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn websocket_v2_first_turn_uses_updated_fast_tier_after_startup_prewarm() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_websocket_server(vec![vec![ + vec![ev_response_created("warm-1"), ev_completed("warm-1")], + vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "fast"), + ev_completed("resp-1"), + ], + ]]) + .await; + + let mut builder = test_codex().with_config(|config| { + config.features.enable(Feature::ResponsesWebsocketsV2); + }); + let test = builder.build_with_websocket_server(&server).await?; + + let warmup = server.wait_for_request(0, 0).await.body_json(); + assert_eq!(warmup["type"].as_str(), Some("response.create")); + assert_eq!(warmup["generate"].as_bool(), Some(false)); + assert_eq!(warmup.get("service_tier"), None); + + test.submit_turn_with_service_tier("hello", Some(ServiceTier::Fast)) + .await?; + + assert_eq!(server.handshakes().len(), 1); + let connection = server.single_connection(); + assert_eq!(connection.len(), 2); + let first_turn = connection + .get(1) + .expect("missing first turn request") + .body_json(); + + assert_eq!(first_turn["type"].as_str(), Some("response.create")); + assert_eq!(first_turn["service_tier"].as_str(), Some("priority")); + assert_eq!(first_turn.get("previous_response_id"), None); + assert!( + first_turn + .get("input") + .and_then(Value::as_array) + .is_some_and(|items| !items.is_empty()) + ); + + server.shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn websocket_v2_first_turn_drops_fast_tier_after_startup_prewarm() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_websocket_server(vec![vec![ + vec![ev_response_created("warm-1"), ev_completed("warm-1")], + vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "standard"), + ev_completed("resp-1"), + ], + ]]) + .await; + + let mut builder = test_codex().with_config(|config| { + config.features.enable(Feature::ResponsesWebsocketsV2); + config.service_tier = Some(ServiceTier::Fast); + }); + let test = builder.build_with_websocket_server(&server).await?; + + let warmup = server.wait_for_request(0, 0).await.body_json(); + assert_eq!(warmup["type"].as_str(), Some("response.create")); + assert_eq!(warmup["generate"].as_bool(), Some(false)); + assert_eq!(warmup["service_tier"].as_str(), Some("priority")); + + test.submit_turn_with_service_tier("hello", None).await?; + + assert_eq!(server.handshakes().len(), 1); + let connection = server.single_connection(); + assert_eq!(connection.len(), 2); + let first_turn = connection + .get(1) + .expect("missing first turn request") + .body_json(); + + assert_eq!(first_turn["type"].as_str(), Some("response.create")); + assert_eq!(first_turn.get("service_tier"), None); + assert_eq!(first_turn.get("previous_response_id"), None); + assert!( + first_turn + .get("input") + .and_then(Value::as_array) + .is_some_and(|items| !items.is_empty()) + ); + + server.shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn websocket_v2_next_turn_uses_updated_service_tier() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_websocket_server(vec![vec![ + vec![ev_response_created("warm-1"), ev_completed("warm-1")], + vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "fast"), + ev_completed("resp-1"), + ], + vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-2", "standard"), + ev_completed("resp-2"), + ], + ]]) + .await; + + let mut builder = test_codex().with_config(|config| { + config.features.enable(Feature::ResponsesWebsocketsV2); + }); + let test = builder.build_with_websocket_server(&server).await?; + + let warmup = server.wait_for_request(0, 0).await.body_json(); + assert_eq!(warmup["type"].as_str(), Some("response.create")); + assert_eq!(warmup["generate"].as_bool(), Some(false)); + assert_eq!(warmup.get("service_tier"), None); + + test.submit_turn_with_service_tier("first", Some(ServiceTier::Fast)) + .await?; + test.submit_turn_with_service_tier("second", None).await?; + + assert_eq!(server.handshakes().len(), 1); + let connection = server.single_connection(); + assert_eq!(connection.len(), 3); + + let first_turn = connection + .get(1) + .expect("missing first turn request") + .body_json(); + let second_turn = connection + .get(2) + .expect("missing second turn request") + .body_json(); + + assert_eq!(first_turn["type"].as_str(), Some("response.create")); + assert_eq!(first_turn["service_tier"].as_str(), Some("priority")); + assert_eq!(first_turn.get("previous_response_id"), None); + assert!( + first_turn + .get("input") + .and_then(Value::as_array) + .is_some_and(|items| !items.is_empty()) + ); + + assert_eq!(second_turn["type"].as_str(), Some("response.create")); + assert_eq!(second_turn.get("service_tier"), None); + assert_eq!(second_turn.get("previous_response_id"), None); + assert!( + second_turn + .get("input") + .and_then(Value::as_array) + .is_some_and(|items| !items.is_empty()) + ); + + server.shutdown().await; + Ok(()) +} diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index 7f20f468d9..112b1c1bed 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -312,6 +312,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -907,6 +908,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -987,6 +989,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -1137,6 +1140,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -1200,6 +1204,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -1271,6 +1276,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -1342,6 +1348,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 64a1a82573..3f82139fe3 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -554,6 +554,7 @@ async fn submit_turn( model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 1de0522cb5..d4a8ce9f3e 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -4,7 +4,6 @@ use codex_core::ModelProviderInfo; use codex_core::NewThread; use codex_core::Prompt; use codex_core::ResponseEvent; -use codex_core::ResponsesWebsocketVersion; use codex_core::ThreadManager; use codex_core::WireApi; use codex_core::auth::AuthCredentialsStoreMode; @@ -1128,6 +1127,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re .model_reasoning_summary .unwrap_or(ReasoningSummary::Auto), ), + service_tier: None, collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, @@ -1240,6 +1240,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() model: session_configured.model, effort: None, summary: Some(ReasoningSummary::Concise), + service_tier: None, collaboration_mode: None, final_output_json_schema: None, personality: None, @@ -1641,7 +1642,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { provider.clone(), SessionSource::Exec, config.model_verbosity, - None::, + false, false, false, None, @@ -1718,6 +1719,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { effort, summary.unwrap_or(ReasoningSummary::Auto), None, + None, ) .await .expect("responses stream to start"); diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 8ded6da3e7..b2098c039c 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -16,6 +16,7 @@ use codex_otel::metrics::MetricsConfig; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; use codex_protocol::models::BaseInstructions; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; @@ -30,8 +31,6 @@ use core_test_support::responses::WebSocketConnectionConfig; use core_test_support::responses::WebSocketTestServer; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; -use core_test_support::responses::ev_done; -use core_test_support::responses::ev_done_with_id; use core_test_support::responses::ev_response_created; use core_test_support::responses::start_websocket_server; use core_test_support::responses::start_websocket_server_with_headers; @@ -49,7 +48,6 @@ use tracing_test::traced_test; const MODEL: &str = "gpt-5.2-codex"; const OPENAI_BETA_HEADER: &str = "OpenAI-Beta"; -const OPENAI_BETA_RESPONSES_WEBSOCKETS: &str = "responses_websockets=2026-02-04"; const WS_V2_BETA_HEADER_VALUE: &str = "responses_websockets=2026-02-06"; struct WebsocketTestHarness { @@ -88,7 +86,7 @@ async fn responses_websocket_streams_request() { let handshake = server.single_handshake(); assert_eq!( handshake.header(OPENAI_BETA_HEADER), - Some(OPENAI_BETA_RESPONSES_WEBSOCKETS.to_string()) + Some(WS_V2_BETA_HEADER_VALUE.to_string()) ); server.shutdown().await; @@ -124,7 +122,7 @@ async fn responses_websocket_request_prewarm_reuses_connection() { skip_if_no_network!(); let server = start_websocket_server(vec![vec![ - vec![ev_response_created("warm-1"), ev_done_with_id("warm-1")], + vec![ev_response_created("warm-1"), ev_completed("warm-1")], vec![ev_response_created("resp-1"), ev_completed("resp-1")], ]]) .await; @@ -140,6 +138,7 @@ async fn responses_websocket_request_prewarm_reuses_connection() { harness.effort, harness.summary, None, + None, ) .await .expect("websocket prewarm failed"); @@ -220,6 +219,7 @@ async fn responses_websocket_preconnect_is_reused_even_with_header_changes() { harness.effort, harness.summary, None, + None, ) .await .expect("websocket stream failed"); @@ -241,7 +241,7 @@ async fn responses_websocket_request_prewarm_is_reused_even_with_header_changes( skip_if_no_network!(); let server = start_websocket_server(vec![vec![ - vec![ev_response_created("warm-1"), ev_done_with_id("warm-1")], + vec![ev_response_created("warm-1"), ev_completed("warm-1")], vec![ev_response_created("resp-1"), ev_completed("resp-1")], ]]) .await; @@ -257,6 +257,7 @@ async fn responses_websocket_request_prewarm_is_reused_even_with_header_changes( harness.effort, harness.summary, None, + None, ) .await .expect("websocket prewarm failed"); @@ -268,6 +269,7 @@ async fn responses_websocket_request_prewarm_is_reused_even_with_header_changes( harness.effort, harness.summary, None, + None, ) .await .expect("websocket stream failed"); @@ -320,6 +322,7 @@ async fn responses_websocket_prewarm_uses_v2_when_model_prefers_websockets_and_f harness.effort, harness.summary, None, + None, ) .await .expect("websocket prewarm failed"); @@ -338,13 +341,6 @@ async fn responses_websocket_prewarm_uses_v2_when_model_prefers_websockets_and_f .map(str::trim) .any(|value| value == WS_V2_BETA_HEADER_VALUE) ); - assert!( - !openai_beta_header - .split(',') - .map(str::trim) - .any(|value| value == OPENAI_BETA_RESPONSES_WEBSOCKETS) - ); - stream_until_complete(&mut client_session, &harness, &prompt).await; assert_eq!(server.handshakes().len(), 1); let connection = server.single_connection(); @@ -398,13 +394,6 @@ async fn responses_websocket_preconnect_runs_when_only_v2_feature_enabled() { .map(str::trim) .any(|value| value == WS_V2_BETA_HEADER_VALUE) ); - assert!( - !openai_beta_header - .split(',') - .map(str::trim) - .any(|value| value == OPENAI_BETA_RESPONSES_WEBSOCKETS) - ); - server.shutdown().await; } @@ -416,7 +405,7 @@ async fn responses_websocket_v2_requests_use_v2_when_model_prefers_websockets() vec![ ev_response_created("resp-1"), ev_assistant_message("msg-1", "assistant output"), - ev_done_with_id("resp-1"), + ev_completed("resp-1"), ], vec![ev_response_created("resp-2"), ev_completed("resp-2")], ]]) @@ -454,13 +443,6 @@ async fn responses_websocket_v2_requests_use_v2_when_model_prefers_websockets() .map(str::trim) .any(|value| value == WS_V2_BETA_HEADER_VALUE) ); - assert!( - !openai_beta_header - .split(',') - .map(str::trim) - .any(|value| value == OPENAI_BETA_RESPONSES_WEBSOCKETS) - ); - server.shutdown().await; } @@ -472,7 +454,7 @@ async fn responses_websocket_v2_incremental_requests_are_reused_across_turns() { vec![ ev_response_created("resp-1"), ev_assistant_message("msg-1", "assistant output"), - ev_done_with_id("resp-1"), + ev_completed("resp-1"), ], vec![ev_response_created("resp-2"), ev_completed("resp-2")], ]]) @@ -516,7 +498,7 @@ async fn responses_websocket_v2_wins_when_both_features_enabled() { vec![ ev_response_created("resp-1"), ev_assistant_message("msg-1", "assistant output"), - ev_done_with_id("resp-1"), + ev_completed("resp-1"), ], vec![ev_response_created("resp-2"), ev_completed("resp-2")], ]]) @@ -554,13 +536,6 @@ async fn responses_websocket_v2_wins_when_both_features_enabled() { .map(str::trim) .any(|value| value == WS_V2_BETA_HEADER_VALUE) ); - assert!( - !openai_beta_header - .split(',') - .map(str::trim) - .any(|value| value == OPENAI_BETA_RESPONSES_WEBSOCKETS) - ); - server.shutdown().await; } @@ -693,6 +668,7 @@ async fn responses_websocket_emits_reasoning_included_event() { harness.effort, harness.summary, None, + None, ) .await .expect("websocket stream failed"); @@ -764,6 +740,7 @@ async fn responses_websocket_emits_rate_limit_events() { harness.effort, harness.summary, None, + None, ) .await .expect("websocket stream failed"); @@ -827,7 +804,14 @@ async fn responses_websocket_usage_limit_error_emits_rate_limit_event() { } }); - let server = start_websocket_server(vec![vec![vec![usage_limit_error]]]).await; + let server = start_websocket_server(vec![vec![ + vec![ + ev_response_created("resp-prewarm"), + ev_completed("resp-prewarm"), + ], + vec![usage_limit_error], + ]]) + .await; let mut builder = test_codex().with_config(|config| { config.model_provider.request_max_retries = Some(0); config.model_provider.stream_max_retries = Some(0); @@ -905,7 +889,14 @@ async fn responses_websocket_invalid_request_error_with_status_is_forwarded() { } }); - let server = start_websocket_server(vec![vec![vec![invalid_request_error]]]).await; + let server = start_websocket_server(vec![vec![ + vec![ + ev_response_created("resp-prewarm"), + ev_completed("resp-prewarm"), + ], + vec![invalid_request_error], + ]]) + .await; let mut builder = test_codex().with_config(|config| { config.model_provider.request_max_retries = Some(0); config.model_provider.stream_max_retries = Some(0); @@ -982,14 +973,14 @@ async fn responses_websocket_connection_limit_error_reconnects_and_completes() { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn responses_websocket_appends_on_prefix() { +async fn responses_websocket_uses_incremental_create_on_prefix() { skip_if_no_network!(); let server = start_websocket_server(vec![vec![ vec![ ev_response_created("resp-1"), ev_assistant_message("msg-1", "assistant output"), - ev_done(), + ev_completed("resp-1"), ], vec![ev_response_created("resp-2"), ev_completed("resp-2")], ]]) @@ -1016,24 +1007,25 @@ async fn responses_websocket_appends_on_prefix() { assert_eq!(first["model"].as_str(), Some(MODEL)); assert_eq!(first["stream"], serde_json::Value::Bool(true)); assert_eq!(first["input"].as_array().map(Vec::len), Some(1)); - let expected_append = serde_json::json!({ - "type": "response.append", - "input": serde_json::to_value(&prompt_two.input[2..]).expect("serialize append items"), - }); - assert_eq!(second, expected_append); + assert_eq!(second["type"].as_str(), Some("response.create")); + assert_eq!(second["previous_response_id"].as_str(), Some("resp-1")); + assert_eq!( + second["input"], + serde_json::to_value(&prompt_two.input[2..]).expect("serialize incremental items") + ); server.shutdown().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn responses_websocket_forwards_turn_metadata_on_create_and_append() { +async fn responses_websocket_forwards_turn_metadata_on_initial_and_incremental_create() { skip_if_no_network!(); let server = start_websocket_server(vec![vec![ vec![ ev_response_created("resp-1"), ev_assistant_message("msg-1", "assistant output"), - ev_done(), + ev_completed("resp-1"), ], vec![ev_response_created("resp-2"), ev_completed("resp-2")], ]]) @@ -1054,6 +1046,7 @@ async fn responses_websocket_forwards_turn_metadata_on_create_and_append() { &mut client_session, &harness, &prompt_one, + None, Some(first_turn_metadata), ) .await; @@ -1061,6 +1054,7 @@ async fn responses_websocket_forwards_turn_metadata_on_create_and_append() { &mut client_session, &harness, &prompt_two, + None, Some(enriched_turn_metadata), ) .await; @@ -1075,7 +1069,8 @@ async fn responses_websocket_forwards_turn_metadata_on_create_and_append() { first["client_metadata"]["x-codex-turn-metadata"].as_str(), Some(first_turn_metadata) ); - assert_eq!(second["type"].as_str(), Some("response.append")); + assert_eq!(second["type"].as_str(), Some("response.create")); + assert_eq!(second["previous_response_id"].as_str(), Some("resp-1")); assert_eq!( second["client_metadata"]["x-codex-turn-metadata"].as_str(), Some(enriched_turn_metadata) @@ -1097,7 +1092,7 @@ async fn responses_websocket_forwards_turn_metadata_on_create_and_append() { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn responses_websocket_creates_on_prefix_when_previous_completion_cannot_append() { +async fn responses_websocket_uses_previous_response_id_when_prefix_after_completed() { skip_if_no_network!(); let server = start_websocket_server(vec![vec![ @@ -1127,9 +1122,10 @@ async fn responses_websocket_creates_on_prefix_when_previous_completion_cannot_a let second = connection.get(1).expect("missing request").body_json(); assert_eq!(second["type"].as_str(), Some("response.create")); + assert_eq!(second["previous_response_id"].as_str(), Some("resp-1")); assert_eq!( second["input"], - serde_json::to_value(&prompt_two.input).expect("serialize full input") + serde_json::to_value(&prompt_two.input[2..]).expect("serialize incremental input") ); server.shutdown().await; @@ -1212,7 +1208,7 @@ async fn responses_websocket_v2_creates_with_previous_response_id_on_prefix() { vec![ ev_response_created("resp-1"), ev_assistant_message("msg-1", "assistant output"), - ev_done_with_id("resp-1"), + ev_completed("resp-1"), ], vec![ev_response_created("resp-2"), ev_completed("resp-2")], ]]) @@ -1324,6 +1320,7 @@ async fn responses_websocket_v2_after_error_uses_full_create_without_previous_re harness.effort, harness.summary, None, + None, ) .await .expect("websocket stream failed"); @@ -1398,13 +1395,6 @@ async fn responses_websocket_v2_sets_openai_beta_header() { .map(str::trim) .any(|value| value == WS_V2_BETA_HEADER_VALUE) ); - assert!( - !openai_beta_header - .split(',') - .map(str::trim) - .any(|value| value == OPENAI_BETA_RESPONSES_WEBSOCKETS) - ); - server.shutdown().await; } @@ -1555,13 +1545,24 @@ async fn stream_until_complete( harness: &WebsocketTestHarness, prompt: &Prompt, ) { - stream_until_complete_with_turn_metadata(client_session, harness, prompt, None).await; + stream_until_complete_with_service_tier(client_session, harness, prompt, None).await; +} + +async fn stream_until_complete_with_service_tier( + client_session: &mut ModelClientSession, + harness: &WebsocketTestHarness, + prompt: &Prompt, + service_tier: Option, +) { + stream_until_complete_with_turn_metadata(client_session, harness, prompt, service_tier, None) + .await; } async fn stream_until_complete_with_turn_metadata( client_session: &mut ModelClientSession, harness: &WebsocketTestHarness, prompt: &Prompt, + service_tier: Option, turn_metadata_header: Option<&str>, ) { let mut stream = client_session @@ -1571,6 +1572,7 @@ async fn stream_until_complete_with_turn_metadata( &harness.otel_manager, harness.effort, harness.summary, + service_tier, turn_metadata_header, ) .await diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index b1dbd2d660..781f226cb5 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -119,6 +119,7 @@ async fn user_input_includes_collaboration_instructions_after_override() -> Resu model: None, effort: None, summary: None, + service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, }) @@ -174,6 +175,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { .model_reasoning_summary .unwrap_or(codex_protocol::config_types::ReasoningSummary::Auto), ), + service_tier: None, collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, @@ -213,6 +215,7 @@ async fn override_then_next_turn_uses_updated_collaboration_instructions() -> Re model: None, effort: None, summary: None, + service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, }) @@ -263,6 +266,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu model: None, effort: None, summary: None, + service_tier: None, collaboration_mode: Some(base_mode), personality: None, }) @@ -284,6 +288,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu .model_reasoning_summary .unwrap_or(codex_protocol::config_types::ReasoningSummary::Auto), ), + service_tier: None, collaboration_mode: Some(turn_mode), final_output_json_schema: None, personality: None, @@ -330,6 +335,7 @@ async fn collaboration_mode_update_emits_new_instruction_message() -> Result<()> model: None, effort: None, summary: None, + service_tier: None, collaboration_mode: Some(collab_mode_with_instructions(Some(first_text))), personality: None, }) @@ -355,6 +361,7 @@ async fn collaboration_mode_update_emits_new_instruction_message() -> Result<()> model: None, effort: None, summary: None, + service_tier: None, collaboration_mode: Some(collab_mode_with_instructions(Some(second_text))), personality: None, }) @@ -409,6 +416,7 @@ async fn collaboration_mode_update_noop_does_not_append() -> Result<()> { model: None, effort: None, summary: None, + service_tier: None, collaboration_mode: Some(collab_mode_with_instructions(Some(collab_text))), personality: None, }) @@ -434,6 +442,7 @@ async fn collaboration_mode_update_noop_does_not_append() -> Result<()> { model: None, effort: None, summary: None, + service_tier: None, collaboration_mode: Some(collab_mode_with_instructions(Some(collab_text))), personality: None, }) @@ -487,6 +496,7 @@ async fn collaboration_mode_update_emits_new_instruction_message_when_mode_chang model: None, effort: None, summary: None, + service_tier: None, collaboration_mode: Some(collab_mode_with_mode_and_instructions( ModeKind::Default, Some(default_text), @@ -515,6 +525,7 @@ async fn collaboration_mode_update_emits_new_instruction_message_when_mode_chang model: None, effort: None, summary: None, + service_tier: None, collaboration_mode: Some(collab_mode_with_mode_and_instructions( ModeKind::Plan, Some(plan_text), @@ -572,6 +583,7 @@ async fn collaboration_mode_update_noop_does_not_append_when_mode_is_unchanged() model: None, effort: None, summary: None, + service_tier: None, collaboration_mode: Some(collab_mode_with_mode_and_instructions( ModeKind::Default, Some(collab_text), @@ -600,6 +612,7 @@ async fn collaboration_mode_update_noop_does_not_append_when_mode_is_unchanged() model: None, effort: None, summary: None, + service_tier: None, collaboration_mode: Some(collab_mode_with_mode_and_instructions( ModeKind::Default, Some(collab_text), @@ -663,6 +676,7 @@ async fn resume_replays_collaboration_instructions() -> Result<()> { model: None, effort: None, summary: None, + service_tier: None, collaboration_mode: Some(collab_mode_with_instructions(Some(collab_text))), personality: None, }) @@ -724,6 +738,7 @@ async fn empty_collaboration_instructions_are_ignored() -> Result<()> { model: None, effort: None, summary: None, + service_tier: None, collaboration_mode: Some(CollaborationMode { mode: ModeKind::Default, settings: Settings { diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 618861f33e..ac6ffc915d 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1659,6 +1659,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { model: resumed.session_configured.model.clone(), effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -1748,6 +1749,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { model: previous_model.to_string(), effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -1771,6 +1773,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { model: next_model.to_string(), effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -1880,6 +1883,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { model: previous_model.to_string(), effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -1927,6 +1931,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { model: next_model.to_string(), effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -3012,6 +3017,7 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess model: None, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -3128,6 +3134,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch model: previous_model.to_string(), effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -3151,6 +3158,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch model: next_model.to_string(), effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index 2232988787..b336ce100e 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -86,7 +86,7 @@ fn remote_realtime_test_codex_builder( ) -> TestCodexBuilder { let realtime_base_url = realtime_server.uri().to_string(); test_codex() - .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_auth(CodexAuth::from_api_key("dummy")) .with_config(move |config| { config.experimental_realtime_ws_base_url = Some(realtime_base_url); }) @@ -95,8 +95,8 @@ fn remote_realtime_test_codex_builder( async fn start_remote_realtime_server() -> responses::WebSocketTestServer { start_websocket_server(vec![vec![ vec![json!({ - "type": "session.created", - "session": { "id": "sess_remote_compact" } + "type": "session.updated", + "session": { "id": "sess_remote_compact", "instructions": "backend prompt" } })], // Keep the websocket open after startup so routed transcript items during the test do not // exhaust the scripted responses and mark realtime inactive before the assertions run. @@ -130,7 +130,7 @@ async fn start_realtime_conversation(codex: &codex_core::CodexThread) -> Result< wait_for_event_match(codex, |msg| match msg { EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { - payload: RealtimeEvent::SessionCreated { session_id }, + payload: RealtimeEvent::SessionUpdated { session_id, .. }, }) => Some(session_id.clone()), _ => None, }) @@ -1926,6 +1926,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_including_incoming_us model: None, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -2035,6 +2036,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model model: Some(next_model.to_string()), effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index 52717f2880..bf8416e97d 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -55,6 +55,7 @@ async fn submit_user_turn( model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode, personality: None, }) @@ -134,6 +135,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index c8f1f2eaf1..07b51c7629 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -126,6 +126,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -208,6 +209,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index dfb5ac88b4..af1a83f53b 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -378,6 +378,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< model: session_configured.model.clone(), effort: None, summary: None, + service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, }) @@ -453,6 +454,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { model: session_configured.model.clone(), effort: None, summary: None, + service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, }) @@ -560,6 +562,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done model: session_configured.model.clone(), effort: None, summary: None, + service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, }) @@ -745,6 +748,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p model: session_configured.model.clone(), effort: None, summary: None, + service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, }) @@ -857,6 +861,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { model: session_configured.model.clone(), effort: None, summary: None, + service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, }) diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index 12c9845a75..f7dfd02daf 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -84,6 +84,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index 8ee4c56dda..2192bdffd9 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -65,6 +65,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/model_overrides.rs b/codex-rs/core/tests/suite/model_overrides.rs index 1ba469f7f9..3760c9a38a 100644 --- a/codex-rs/core/tests/suite/model_overrides.rs +++ b/codex-rs/core/tests/suite/model_overrides.rs @@ -33,6 +33,7 @@ async fn override_turn_context_does_not_persist_when_config_exists() { model: Some("o3".to_string()), effort: Some(Some(ReasoningEffort::High)), summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -69,6 +70,7 @@ async fn override_turn_context_does_not_create_config_file() { model: Some("o3".to_string()), effort: Some(Some(ReasoningEffort::Medium)), summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 62d0545462..385072f912 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -4,6 +4,7 @@ use codex_core::config::types::Personality; use codex_core::features::Feature; use codex_core::models_manager::manager::RefreshStrategy; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ConfigShellToolType; use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::ModelInfo; @@ -59,6 +60,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -74,6 +76,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( model: Some(next_model.to_string()), effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -92,6 +95,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( model: next_model.to_string(), effort: test.config.model_reasoning_effort, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -147,6 +151,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -162,6 +167,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul model: Some(next_model.to_string()), effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: Some(Personality::Pragmatic), }) @@ -180,6 +186,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul model: next_model.to_string(), effort: test.config.model_reasoning_effort, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -207,6 +214,36 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn service_tier_change_is_applied_on_next_http_turn() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let resp_mock = mount_sse_sequence( + &server, + vec![sse_completed("resp-1"), sse_completed("resp-2")], + ) + .await; + + let test = test_codex().build(&server).await?; + + test.submit_turn_with_service_tier("fast turn", Some(ServiceTier::Fast)) + .await?; + test.submit_turn_with_service_tier("standard turn", None) + .await?; + + let requests = resp_mock.requests(); + assert_eq!(requests.len(), 2, "expected two model requests"); + + let first_body = requests[0].body_json(); + let second_body = requests[1].body_json(); + + assert_eq!(first_body["service_tier"].as_str(), Some("priority")); + assert_eq!(second_body.get("service_tier"), None); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn model_change_from_image_to_text_strips_prior_image_content() -> Result<()> { skip_if_no_network!(Ok(())); @@ -296,6 +333,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< model: image_model_slug.to_string(), effort: test.config.model_reasoning_effort, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -315,6 +353,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< model: text_model_slug.to_string(), effort: test.config.model_reasoning_effort, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -474,6 +513,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< model: large_model_slug.to_string(), effort: test.config.model_reasoning_effort, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -511,6 +551,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< model: Some(smaller_model_slug.to_string()), effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -529,6 +570,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< model: smaller_model_slug.to_string(), effort: test.config.model_reasoning_effort, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 14503b3f5b..a6a044f3f6 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -122,6 +122,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -144,6 +145,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, + service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), }) @@ -221,6 +223,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -243,6 +246,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -348,6 +352,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul model: resumed.session_configured.model.clone(), effort: resumed.config.model_reasoning_effort, summary: None, + service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), }) @@ -436,6 +441,7 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() - model: Some("gpt-5.2".to_string()), effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 9948eb4e21..d2202b58cf 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -100,6 +100,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { model: test.session_configured.model.clone(), effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index 1cdc549012..a479ebb80c 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -106,6 +106,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/override_updates.rs b/codex-rs/core/tests/suite/override_updates.rs index 7ae78891f1..dbf19a2e42 100644 --- a/codex-rs/core/tests/suite/override_updates.rs +++ b/codex-rs/core/tests/suite/override_updates.rs @@ -121,6 +121,7 @@ async fn override_turn_context_without_user_turn_does_not_record_permissions_upd model: None, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -161,6 +162,7 @@ async fn override_turn_context_without_user_turn_does_not_record_environment_upd model: None, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -198,6 +200,7 @@ async fn override_turn_context_without_user_turn_does_not_record_collaboration_u model: None, effort: None, summary: None, + service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, }) diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index 36c93b3796..8dfe2b5e6b 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -120,6 +120,7 @@ async fn permissions_message_added_on_override_change() -> Result<()> { model: None, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -262,6 +263,7 @@ async fn resume_replays_permissions_messages() -> Result<()> { model: None, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -361,6 +363,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { model: None, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 97ce2360ba..73f57ba0b0 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -98,6 +98,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -144,6 +145,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -197,6 +199,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -256,6 +259,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -303,6 +307,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -319,6 +324,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> model: None, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), }) @@ -337,6 +343,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -399,6 +406,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -415,6 +423,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho model: None, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: Some(Personality::Pragmatic), }) @@ -433,6 +442,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -505,6 +515,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -521,6 +532,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> model: None, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: Some(Personality::Pragmatic), }) @@ -539,6 +551,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -649,6 +662,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow model: remote_slug.to_string(), effort: test.config.model_reasoning_effort, summary: None, + service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), }) @@ -761,6 +775,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - model: remote_slug.to_string(), effort: test.config.model_reasoning_effort, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -777,6 +792,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - model: None, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), }) @@ -795,6 +811,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - model: remote_slug.to_string(), effort: test.config.model_reasoning_effort, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 54ee11fba4..0275f3b4b2 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -413,6 +413,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an model: None, effort: Some(Some(ReasoningEffort::High)), summary: Some(ReasoningSummary::Detailed), + service_tier: None, collaboration_mode: None, personality: None, }) @@ -494,6 +495,7 @@ async fn override_before_first_turn_emits_environment_context() -> anyhow::Resul model: Some("gpt-5.1-codex".to_string()), effort: Some(Some(ReasoningEffort::Low)), summary: None, + service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, }) @@ -680,6 +682,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res model: "o3".to_string(), effort: Some(ReasoningEffort::High), summary: Some(ReasoningSummary::Detailed), + service_tier: None, collaboration_mode: None, final_output_json_schema: None, personality: None, @@ -788,6 +791,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a model: default_model.clone(), effort: default_effort, summary: Some(default_summary.unwrap_or(ReasoningSummary::Auto)), + service_tier: None, collaboration_mode: None, final_output_json_schema: None, personality: None, @@ -807,6 +811,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a model: default_model.clone(), effort: default_effort, summary: Some(default_summary.unwrap_or(ReasoningSummary::Auto)), + service_tier: None, collaboration_mode: None, final_output_json_schema: None, personality: None, @@ -907,6 +912,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu model: default_model, effort: default_effort, summary: Some(default_summary.unwrap_or(ReasoningSummary::Auto)), + service_tier: None, collaboration_mode: None, final_output_json_schema: None, personality: None, @@ -926,6 +932,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu model: "o3".to_string(), effort: Some(ReasoningEffort::High), summary: Some(ReasoningSummary::Detailed), + service_tier: None, collaboration_mode: None, final_output_json_schema: None, personality: None, diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index b6191bc88e..32500299cb 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -1,4 +1,6 @@ use anyhow::Result; +use codex_core::CodexAuth; +use codex_core::auth::OPENAI_API_KEY_ENV_VAR; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::ConversationAudioParams; use codex_protocol::protocol::ConversationStartParams; @@ -22,6 +24,7 @@ use core_test_support::wait_for_event_match; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; +use std::ffi::OsString; use std::time::Duration; use tokio::sync::oneshot; @@ -33,16 +36,16 @@ async fn conversation_start_audio_text_close_round_trip() -> Result<()> { vec![], vec![ vec![json!({ - "type": "session.created", - "session": { "id": "sess_1" } + "type": "session.updated", + "session": { "id": "sess_1", "instructions": "backend prompt" } })], vec![], vec![ json!({ - "type": "response.output_audio.delta", + "type": "conversation.output_audio.delta", "delta": "AQID", "sample_rate": 24000, - "num_channels": 1 + "channels": 1 }), json!({ "type": "conversation.item.added", @@ -77,14 +80,14 @@ async fn conversation_start_audio_text_close_round_trip() -> Result<()> { .unwrap_or_else(|err: ErrorEvent| panic!("conversation start failed: {err:?}")); assert!(started.session_id.is_some()); - let session_created = wait_for_event_match(&test.codex, |msg| match msg { + let session_updated = wait_for_event_match(&test.codex, |msg| match msg { EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { - payload: RealtimeEvent::SessionCreated { session_id }, + payload: RealtimeEvent::SessionUpdated { session_id, .. }, }) => Some(session_id.clone()), _ => None, }) .await; - assert_eq!(session_created, "sess_1"); + assert_eq!(session_updated, "sess_1"); test.codex .submit(Op::RealtimeConversationAudio(ConversationAudioParams { @@ -117,17 +120,29 @@ async fn conversation_start_audio_text_close_round_trip() -> Result<()> { assert_eq!(connection.len(), 3); assert_eq!( connection[0].body_json()["type"].as_str(), - Some("session.create") + Some("session.update") ); assert_eq!( - connection[0].body_json()["session"]["conversation_id"] - .as_str() - .expect("session.create conversation_id"), + connection[0].body_json()["session"]["instructions"].as_str(), + Some("backend prompt") + ); + assert_eq!( + server.handshakes()[1] + .header("x-session-id") + .expect("session.update x-session-id header"), started .session_id .as_deref() .expect("started session id should be present") ); + assert_eq!( + server.handshakes()[1].header("authorization").as_deref(), + Some("Bearer dummy") + ); + assert_eq!( + server.handshakes()[1].uri(), + "/v1/realtime?intent=quicksilver&model=realtime-test-model" + ); let mut request_types = [ connection[1].body_json()["type"] .as_str() @@ -143,7 +158,7 @@ async fn conversation_start_audio_text_close_round_trip() -> Result<()> { request_types, [ "conversation.item.create".to_string(), - "response.input_audio.delta".to_string(), + "input_audio_buffer.append".to_string(), ] ); @@ -162,15 +177,74 @@ async fn conversation_start_audio_text_close_round_trip() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn conversation_start_uses_openai_env_key_fallback_with_chatgpt_auth() -> Result<()> { + skip_if_no_network!(Ok(())); + + let _env_guard = EnvGuard::set(OPENAI_API_KEY_ENV_VAR, "env-realtime-key"); + let server = start_websocket_server(vec![ + vec![], + vec![vec![json!({ + "type": "session.updated", + "session": { "id": "sess_env", "instructions": "backend prompt" } + })]], + ]) + .await; + + let mut builder = test_codex().with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + let test = builder.build_with_websocket_server(&server).await?; + assert!(server.wait_for_handshakes(1, Duration::from_secs(2)).await); + + test.codex + .submit(Op::RealtimeConversationStart(ConversationStartParams { + prompt: "backend prompt".to_string(), + session_id: None, + })) + .await?; + + let started = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationStarted(started) => Some(Ok(started.clone())), + EventMsg::Error(err) => Some(Err(err.clone())), + _ => None, + }) + .await + .unwrap_or_else(|err: ErrorEvent| panic!("conversation start failed: {err:?}")); + assert!(started.session_id.is_some()); + + let session_updated = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::SessionUpdated { session_id, .. }, + }) => Some(session_id.clone()), + _ => None, + }) + .await; + assert_eq!(session_updated, "sess_env"); + + assert_eq!( + server.handshakes()[1].header("authorization").as_deref(), + Some("Bearer env-realtime-key") + ); + + test.codex.submit(Op::RealtimeConversationClose).await?; + let _closed = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationClosed(closed) => Some(closed.clone()), + _ => None, + }) + .await; + + server.shutdown().await; + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn conversation_transport_close_emits_closed_event() -> Result<()> { skip_if_no_network!(Ok(())); - let session_created = vec![json!({ - "type": "session.created", - "session": { "id": "sess_1" } + let session_updated = vec![json!({ + "type": "session.updated", + "session": { "id": "sess_1", "instructions": "backend prompt" } })]; - let server = start_websocket_server(vec![vec![], vec![session_created]]).await; + let server = start_websocket_server(vec![vec![], vec![session_updated]]).await; let mut builder = test_codex(); let test = builder.build_with_websocket_server(&server).await?; @@ -192,14 +266,14 @@ async fn conversation_transport_close_emits_closed_event() -> Result<()> { .unwrap_or_else(|err: ErrorEvent| panic!("conversation start failed: {err:?}")); assert!(started.session_id.is_some()); - let session_created = wait_for_event_match(&test.codex, |msg| match msg { + let session_updated = wait_for_event_match(&test.codex, |msg| match msg { EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { - payload: RealtimeEvent::SessionCreated { session_id }, + payload: RealtimeEvent::SessionUpdated { session_id, .. }, }) => Some(session_id.clone()), _ => None, }) .await; - assert_eq!(session_created, "sess_1"); + assert_eq!(session_updated, "sess_1"); let closed = wait_for_event_match(&test.codex, |msg| match msg { EventMsg::RealtimeConversationClosed(closed) => Some(closed.clone()), @@ -212,6 +286,34 @@ async fn conversation_transport_close_emits_closed_event() -> Result<()> { Ok(()) } +struct EnvGuard { + key: &'static str, + original: Option, +} + +impl EnvGuard { + fn set(key: &'static str, value: &str) -> Self { + let original = std::env::var_os(key); + // SAFETY: this guard restores the original value before the test exits. + unsafe { + std::env::set_var(key, value); + } + Self { key, original } + } +} + +impl Drop for EnvGuard { + fn drop(&mut self) { + // SAFETY: this guard restores the original value for the modified env var. + unsafe { + match &self.original { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn conversation_audio_before_start_emits_error() -> Result<()> { skip_if_no_network!(Ok(())); @@ -276,19 +378,19 @@ async fn conversation_second_start_replaces_runtime() -> Result<()> { let server = start_websocket_server(vec![ vec![], vec![vec![json!({ - "type": "session.created", - "session": { "id": "sess_old" } + "type": "session.updated", + "session": { "id": "sess_old", "instructions": "old" } })]], vec![ vec![json!({ - "type": "session.created", - "session": { "id": "sess_new" } + "type": "session.updated", + "session": { "id": "sess_new", "instructions": "new" } })], vec![json!({ - "type": "response.output_audio.delta", + "type": "conversation.output_audio.delta", "delta": "AQID", "sample_rate": 24000, - "num_channels": 1 + "channels": 1 })], ], ]) @@ -305,7 +407,7 @@ async fn conversation_second_start_replaces_runtime() -> Result<()> { .await?; wait_for_event_match(&test.codex, |msg| match msg { EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { - payload: RealtimeEvent::SessionCreated { session_id }, + payload: RealtimeEvent::SessionUpdated { session_id, .. }, }) if session_id == "sess_old" => Some(Ok(())), EventMsg::Error(err) => Some(Err(err.clone())), _ => None, @@ -321,7 +423,7 @@ async fn conversation_second_start_replaces_runtime() -> Result<()> { .await?; wait_for_event_match(&test.codex, |msg| match msg { EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { - payload: RealtimeEvent::SessionCreated { session_id }, + payload: RealtimeEvent::SessionUpdated { session_id, .. }, }) if session_id == "sess_new" => Some(Ok(())), EventMsg::Error(err) => Some(Err(err.clone())), _ => None, @@ -351,17 +453,25 @@ async fn conversation_second_start_replaces_runtime() -> Result<()> { assert_eq!(connections.len(), 3); assert_eq!(connections[1].len(), 1); assert_eq!( - connections[1][0].body_json()["session"]["conversation_id"].as_str(), + connections[1][0].body_json()["session"]["instructions"].as_str(), + Some("old") + ); + assert_eq!( + server.handshakes()[1].header("x-session-id").as_deref(), Some("conv_old") ); assert_eq!(connections[2].len(), 2); assert_eq!( - connections[2][0].body_json()["session"]["conversation_id"].as_str(), + connections[2][0].body_json()["session"]["instructions"].as_str(), + Some("new") + ); + assert_eq!( + server.handshakes()[2].header("x-session-id").as_deref(), Some("conv_new") ); assert_eq!( connections[2][1].body_json()["type"].as_str(), - Some("response.input_audio.delta") + Some("input_audio_buffer.append") ); server.shutdown().await; @@ -374,8 +484,8 @@ async fn conversation_uses_experimental_realtime_ws_base_url_override() -> Resul let startup_server = start_websocket_server(vec![vec![]]).await; let realtime_server = start_websocket_server(vec![vec![vec![json!({ - "type": "session.created", - "session": { "id": "sess_override" } + "type": "session.updated", + "session": { "id": "sess_override", "instructions": "backend prompt" } })]]]) .await; @@ -399,14 +509,14 @@ async fn conversation_uses_experimental_realtime_ws_base_url_override() -> Resul })) .await?; - let session_created = wait_for_event_match(&test.codex, |msg| match msg { + let session_updated = wait_for_event_match(&test.codex, |msg| match msg { EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { - payload: RealtimeEvent::SessionCreated { session_id }, + payload: RealtimeEvent::SessionUpdated { session_id, .. }, }) => Some(session_id.clone()), _ => None, }) .await; - assert_eq!(session_created, "sess_override"); + assert_eq!(session_updated, "sess_override"); let startup_connections = startup_server.connections(); assert_eq!(startup_connections.len(), 1); @@ -415,7 +525,7 @@ async fn conversation_uses_experimental_realtime_ws_base_url_override() -> Resul assert_eq!(realtime_connections.len(), 1); assert_eq!( realtime_connections[0][0].body_json()["type"].as_str(), - Some("session.create") + Some("session.update") ); startup_server.shutdown().await; @@ -430,8 +540,8 @@ async fn conversation_uses_experimental_realtime_ws_backend_prompt_override() -> let server = start_websocket_server(vec![ vec![], vec![vec![json!({ - "type": "session.created", - "session": { "id": "sess_override" } + "type": "session.updated", + "session": { "id": "sess_override", "instructions": "prompt from config" } })]], ]) .await; @@ -449,19 +559,19 @@ async fn conversation_uses_experimental_realtime_ws_backend_prompt_override() -> })) .await?; - let session_created = wait_for_event_match(&test.codex, |msg| match msg { + let session_updated = wait_for_event_match(&test.codex, |msg| match msg { EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { - payload: RealtimeEvent::SessionCreated { session_id }, + payload: RealtimeEvent::SessionUpdated { session_id, .. }, }) => Some(session_id.clone()), _ => None, }) .await; - assert_eq!(session_created, "sess_override"); + assert_eq!(session_updated, "sess_override"); let connections = server.connections(); assert_eq!(connections.len(), 2); assert_eq!( - connections[1][0].body_json()["session"]["backend_prompt"].as_str(), + connections[1][0].body_json()["session"]["instructions"].as_str(), Some("prompt from config") ); @@ -470,7 +580,7 @@ async fn conversation_uses_experimental_realtime_ws_backend_prompt_override() -> } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn conversation_mirrors_assistant_message_text_to_realtime_websocket() -> Result<()> { +async fn conversation_mirrors_assistant_message_text_to_realtime_handoff() -> Result<()> { skip_if_no_network!(Ok(())); let api_server = start_mock_server().await; @@ -485,10 +595,19 @@ async fn conversation_mirrors_assistant_message_text_to_realtime_websocket() -> .await; let realtime_server = start_websocket_server(vec![vec![ - vec![json!({ - "type": "session.created", - "session": { "id": "sess_1" } - })], + vec![ + json!({ + "type": "session.updated", + "session": { "id": "sess_1", "instructions": "backend prompt" } + }), + json!({ + "type": "conversation.handoff.requested", + "handoff_id": "handoff_1", + "item_id": "item_1", + "input_transcript": "delegate hello", + "messages": [{ "role": "user", "text": "delegate hello" }] + }), + ], vec![], ]]) .await; @@ -508,16 +627,27 @@ async fn conversation_mirrors_assistant_message_text_to_realtime_websocket() -> })) .await?; - let session_created = wait_for_event_match(&test.codex, |msg| match msg { + let session_updated = wait_for_event_match(&test.codex, |msg| match msg { EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { - payload: RealtimeEvent::SessionCreated { session_id }, + payload: RealtimeEvent::SessionUpdated { session_id, .. }, }) => Some(session_id.clone()), _ => None, }) .await; - assert_eq!(session_created, "sess_1"); + assert_eq!(session_updated, "sess_1"); - test.submit_turn("hello").await?; + let _ = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::HandoffRequested(handoff), + }) if handoff.handoff_id == "handoff_1" => Some(()), + _ => None, + }) + .await; + + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; let deadline = tokio::time::Instant::now() + Duration::from_secs(2); while tokio::time::Instant::now() < deadline { @@ -533,14 +663,18 @@ async fn conversation_mirrors_assistant_message_text_to_realtime_websocket() -> assert_eq!(realtime_connections[0].len(), 2); assert_eq!( realtime_connections[0][0].body_json()["type"].as_str(), - Some("session.create") + Some("session.update") ); assert_eq!( realtime_connections[0][1].body_json()["type"].as_str(), - Some("conversation.item.create") + Some("conversation.handoff.append") ); assert_eq!( - realtime_connections[0][1].body_json()["item"]["content"][0]["text"].as_str(), + realtime_connections[0][1].body_json()["handoff_id"].as_str(), + Some("handoff_1") + ); + assert_eq!( + realtime_connections[0][1].body_json()["output_text"].as_str(), Some("assistant says hi") ); @@ -548,6 +682,145 @@ async fn conversation_mirrors_assistant_message_text_to_realtime_websocket() -> Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn conversation_handoff_persists_across_item_done_until_turn_complete() -> Result<()> { + skip_if_no_network!(Ok(())); + + let (gate_second_message_tx, gate_second_message_rx) = oneshot::channel(); + let first_chunks = vec![ + StreamingSseChunk { + gate: None, + body: sse_event(responses::ev_response_created("resp-1")), + }, + StreamingSseChunk { + gate: None, + body: sse_event(responses::ev_assistant_message( + "msg-1", + "assistant message 1", + )), + }, + StreamingSseChunk { + gate: Some(gate_second_message_rx), + body: sse_event(responses::ev_assistant_message( + "msg-2", + "assistant message 2", + )), + }, + StreamingSseChunk { + gate: None, + body: sse_event(responses::ev_completed("resp-1")), + }, + ]; + let (api_server, completions) = start_streaming_sse_server(vec![first_chunks]).await; + + let realtime_server = start_websocket_server(vec![vec![ + vec![ + json!({ + "type": "session.updated", + "session": { "id": "sess_item_done", "instructions": "backend prompt" } + }), + json!({ + "type": "conversation.handoff.requested", + "handoff_id": "handoff_item_done", + "item_id": "item_item_done", + "input_transcript": "delegate now", + "messages": [{ "role": "user", "text": "delegate now" }] + }), + ], + vec![json!({ + "type": "conversation.item.done", + "item": { "id": "item_item_done" } + })], + vec![], + ]]) + .await; + + let mut builder = test_codex().with_config({ + let realtime_base_url = realtime_server.uri().to_string(); + move |config| { + config.experimental_realtime_ws_base_url = Some(realtime_base_url); + } + }); + let test = builder.build_with_streaming_server(&api_server).await?; + + test.codex + .submit(Op::RealtimeConversationStart(ConversationStartParams { + prompt: "backend prompt".to_string(), + session_id: None, + })) + .await?; + + let _ = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::SessionUpdated { session_id, .. }, + }) if session_id == "sess_item_done" => Some(()), + _ => None, + }) + .await; + + let _ = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::HandoffRequested(handoff), + }) if handoff.handoff_id == "handoff_item_done" => Some(()), + _ => None, + }) + .await; + + let first_append = realtime_server.wait_for_request(0, 1).await; + assert_eq!( + first_append.body_json()["type"].as_str(), + Some("conversation.handoff.append") + ); + assert_eq!( + first_append.body_json()["handoff_id"].as_str(), + Some("handoff_item_done") + ); + assert_eq!( + first_append.body_json()["output_text"].as_str(), + Some("assistant message 1") + ); + + let _ = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::ConversationItemDone { item_id }, + }) if item_id == "item_item_done" => Some(()), + _ => None, + }) + .await; + + let _ = gate_second_message_tx.send(()); + + let second_append = realtime_server.wait_for_request(0, 2).await; + assert_eq!( + second_append.body_json()["type"].as_str(), + Some("conversation.handoff.append") + ); + assert_eq!( + second_append.body_json()["handoff_id"].as_str(), + Some("handoff_item_done") + ); + assert_eq!( + second_append.body_json()["output_text"].as_str(), + Some("assistant message 2") + ); + + let completion = completions + .into_iter() + .next() + .expect("missing delegated turn completion"); + completion + .await + .expect("delegated turn request did not complete"); + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + realtime_server.shutdown().await; + api_server.shutdown().await; + Ok(()) +} + fn sse_event(event: Value) -> String { responses::sse(vec![event]) } @@ -567,7 +840,7 @@ fn message_input_texts(body: &Value, role: &str) -> Vec { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn inbound_realtime_text_starts_turn_for_assistant_role() -> Result<()> { +async fn inbound_handoff_request_starts_turn() -> Result<()> { skip_if_no_network!(Ok(())); let api_server = start_mock_server().await; @@ -583,16 +856,15 @@ async fn inbound_realtime_text_starts_turn_for_assistant_role() -> Result<()> { let realtime_server = start_websocket_server(vec![vec![vec![ json!({ - "type": "session.created", - "session": { "id": "sess_inbound" } + "type": "session.updated", + "session": { "id": "sess_inbound", "instructions": "backend prompt" } }), json!({ - "type": "conversation.item.added", - "item": { - "type": "message", - "role": "assistant", - "content": [{"type": "text", "text": "text from realtime"}] - } + "type": "conversation.handoff.requested", + "handoff_id": "handoff_inbound", + "item_id": "item_inbound", + "input_transcript": "text from realtime", + "messages": [{ "role": "user", "text": "text from realtime" }] }), ]]]) .await; @@ -612,14 +884,26 @@ async fn inbound_realtime_text_starts_turn_for_assistant_role() -> Result<()> { })) .await?; - let session_created = wait_for_event_match(&test.codex, |msg| match msg { + let session_updated = wait_for_event_match(&test.codex, |msg| match msg { EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { - payload: RealtimeEvent::SessionCreated { session_id }, + payload: RealtimeEvent::SessionUpdated { session_id, .. }, }) => Some(session_id.clone()), _ => None, }) .await; - assert_eq!(session_created, "sess_inbound"); + assert_eq!(session_updated, "sess_inbound"); + + let _ = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::HandoffRequested(handoff), + }) if handoff.handoff_id == "handoff_inbound" + && handoff.input_transcript == "text from realtime" => + { + Some(()) + } + _ => None, + }) + .await; wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) @@ -635,15 +919,15 @@ async fn inbound_realtime_text_starts_turn_for_assistant_role() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn inbound_realtime_text_ignores_user_role_and_still_forwards_audio() -> Result<()> { +async fn inbound_conversation_item_does_not_start_turn_and_still_forwards_audio() -> Result<()> { skip_if_no_network!(Ok(())); let api_server = start_mock_server().await; let realtime_server = start_websocket_server(vec![vec![vec![ json!({ - "type": "session.created", - "session": { "id": "sess_ignore_user_role" } + "type": "session.updated", + "session": { "id": "sess_ignore_item", "instructions": "backend prompt" } }), json!({ "type": "conversation.item.added", @@ -654,10 +938,10 @@ async fn inbound_realtime_text_ignores_user_role_and_still_forwards_audio() -> R } }), json!({ - "type": "response.output_audio.delta", + "type": "conversation.output_audio.delta", "delta": "AQID", "sample_rate": 24000, - "num_channels": 1 + "channels": 1 }), ]]]) .await; @@ -679,8 +963,8 @@ async fn inbound_realtime_text_ignores_user_role_and_still_forwards_audio() -> R let _ = wait_for_event_match(&test.codex, |msg| match msg { EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { - payload: RealtimeEvent::SessionCreated { session_id }, - }) if session_id == "sess_ignore_user_role" => Some(()), + payload: RealtimeEvent::SessionUpdated { session_id, .. }, + }) if session_id == "sess_ignore_item" => Some(()), _ => None, }) .await; @@ -695,7 +979,7 @@ async fn inbound_realtime_text_ignores_user_role_and_still_forwards_audio() -> R }), ) .await - .expect("timed out waiting for realtime audio after user-role conversation item"); + .expect("timed out waiting for realtime audio after conversation item"); assert_eq!(audio_out.data, "AQID"); let unexpected_turn_started = tokio::time::timeout( @@ -741,16 +1025,15 @@ async fn delegated_turn_user_role_echo_does_not_redelegate_and_still_forwards_au let realtime_server = start_websocket_server(vec![vec![ vec![ json!({ - "type": "session.created", - "session": { "id": "sess_echo_guard" } + "type": "session.updated", + "session": { "id": "sess_echo_guard", "instructions": "backend prompt" } }), json!({ - "type": "conversation.item.added", - "item": { - "type": "message", - "role": "assistant", - "content": [{"type": "text", "text": "delegate now"}] - } + "type": "conversation.handoff.requested", + "handoff_id": "handoff_echo_guard", + "item_id": "item_echo_guard", + "input_transcript": "delegate now", + "messages": [{"role": "user", "text": "delegate now"}] }), ], vec![ @@ -763,10 +1046,10 @@ async fn delegated_turn_user_role_echo_does_not_redelegate_and_still_forwards_au } }), json!({ - "type": "response.output_audio.delta", + "type": "conversation.output_audio.delta", "delta": "AQID", "sample_rate": 24000, - "num_channels": 1 + "channels": 1 }), ], ]]) @@ -789,7 +1072,7 @@ async fn delegated_turn_user_role_echo_does_not_redelegate_and_still_forwards_au let _ = wait_for_event_match(&test.codex, |msg| match msg { EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { - payload: RealtimeEvent::SessionCreated { session_id }, + payload: RealtimeEvent::SessionUpdated { session_id, .. }, }) if session_id == "sess_echo_guard" => Some(()), _ => None, }) @@ -797,14 +1080,8 @@ async fn delegated_turn_user_role_echo_does_not_redelegate_and_still_forwards_au let _ = wait_for_event_match(&test.codex, |msg| match msg { EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { - payload: RealtimeEvent::ConversationItemAdded(item), - }) => item - .get("content") - .and_then(Value::as_array) - .into_iter() - .flatten() - .any(|content| content.get("text").and_then(Value::as_str) == Some("delegate now")) - .then_some(()), + payload: RealtimeEvent::HandoffRequested(handoff), + }) if handoff.input_transcript == "delegate now" => Some(()), _ => None, }) .await; @@ -817,19 +1094,22 @@ async fn delegated_turn_user_role_echo_does_not_redelegate_and_still_forwards_au let mirrored_request = realtime_server.wait_for_request(0, 1).await; let mirrored_request_body = mirrored_request.body_json(); eprintln!( - "[realtime test +{}ms] saw mirrored request type={:?} role={:?} text={:?} data={:?}", + "[realtime test +{}ms] saw mirrored request type={:?} handoff_id={:?} text={:?}", start.elapsed().as_millis(), mirrored_request_body["type"].as_str(), - mirrored_request_body["item"]["role"].as_str(), - mirrored_request_body["item"]["content"][0]["text"].as_str(), - mirrored_request_body["item"]["content"][0]["data"].as_str(), + mirrored_request_body["handoff_id"].as_str(), + mirrored_request_body["output_text"].as_str(), ); assert_eq!( mirrored_request_body["type"].as_str(), - Some("conversation.item.create") + Some("conversation.handoff.append") ); assert_eq!( - mirrored_request_body["item"]["content"][0]["text"].as_str(), + mirrored_request_body["handoff_id"].as_str(), + Some("handoff_echo_guard") + ); + assert_eq!( + mirrored_request_body["output_text"].as_str(), Some("assistant says hi") ); @@ -875,7 +1155,7 @@ async fn delegated_turn_user_role_echo_does_not_redelegate_and_still_forwards_au } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn inbound_realtime_text_does_not_block_realtime_event_forwarding() -> Result<()> { +async fn inbound_handoff_request_does_not_block_realtime_event_forwarding() -> Result<()> { skip_if_no_network!(Ok(())); let (gate_completed_tx, gate_completed_rx) = oneshot::channel(); @@ -893,22 +1173,21 @@ async fn inbound_realtime_text_does_not_block_realtime_event_forwarding() -> Res let realtime_server = start_websocket_server(vec![vec![vec![ json!({ - "type": "session.created", - "session": { "id": "sess_non_blocking" } + "type": "session.updated", + "session": { "id": "sess_non_blocking", "instructions": "backend prompt" } }), json!({ - "type": "conversation.item.added", - "item": { - "type": "message", - "role": "assistant", - "content": [{"type": "text", "text": "delegate now"}] - } + "type": "conversation.handoff.requested", + "handoff_id": "handoff_non_blocking", + "item_id": "item_non_blocking", + "input_transcript": "delegate now", + "messages": [{"role": "user", "text": "delegate now"}] }), json!({ - "type": "response.output_audio.delta", + "type": "conversation.output_audio.delta", "delta": "AQID", "sample_rate": 24000, - "num_channels": 1 + "channels": 1 }), ]]]) .await; @@ -930,7 +1209,7 @@ async fn inbound_realtime_text_does_not_block_realtime_event_forwarding() -> Res let _ = wait_for_event_match(&test.codex, |msg| match msg { EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { - payload: RealtimeEvent::SessionCreated { session_id }, + payload: RealtimeEvent::SessionUpdated { session_id, .. }, }) if session_id == "sess_non_blocking" => Some(()), _ => None, }) @@ -938,14 +1217,8 @@ async fn inbound_realtime_text_does_not_block_realtime_event_forwarding() -> Res let _ = wait_for_event_match(&test.codex, |msg| match msg { EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { - payload: RealtimeEvent::ConversationItemAdded(item), - }) => item - .get("content") - .and_then(Value::as_array) - .into_iter() - .flatten() - .any(|content| content.get("text").and_then(Value::as_str) == Some("delegate now")) - .then_some(()), + payload: RealtimeEvent::HandoffRequested(handoff), + }) if handoff.input_transcript == "delegate now" => Some(()), _ => None, }) .await; @@ -982,7 +1255,7 @@ async fn inbound_realtime_text_does_not_block_realtime_event_forwarding() -> Res } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn inbound_realtime_text_steers_active_turn() -> Result<()> { +async fn inbound_handoff_request_steers_active_turn() -> Result<()> { skip_if_no_network!(Ok(())); let (gate_completed_tx, gate_completed_rx) = oneshot::channel(); @@ -1027,17 +1300,15 @@ async fn inbound_realtime_text_steers_active_turn() -> Result<()> { let realtime_server = start_websocket_server(vec![vec![ vec![json!({ - "type": "session.created", - "session": { "id": "sess_steer" } + "type": "session.updated", + "session": { "id": "sess_steer", "instructions": "backend prompt" } })], - vec![], vec![json!({ - "type": "conversation.item.added", - "item": { - "type": "message", - "role": "assistant", - "content": [{"type": "text", "text": "steer via realtime"}] - } + "type": "conversation.handoff.requested", + "handoff_id": "handoff_steer", + "item_id": "item_steer", + "input_transcript": "steer via realtime", + "messages": [{ "role": "user", "text": "steer via realtime" }] })], ]]) .await; @@ -1058,7 +1329,7 @@ async fn inbound_realtime_text_steers_active_turn() -> Result<()> { .await?; let _ = wait_for_event_match(&test.codex, |msg| match msg { EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { - payload: RealtimeEvent::SessionCreated { session_id }, + payload: RealtimeEvent::SessionUpdated { session_id, .. }, }) if session_id == "sess_steer" => Some(()), _ => None, }) @@ -1092,16 +1363,8 @@ async fn inbound_realtime_text_steers_active_turn() -> Result<()> { let _ = wait_for_event_match(&test.codex, |msg| match msg { EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { - payload: RealtimeEvent::ConversationItemAdded(item), - }) => item - .get("content") - .and_then(Value::as_array) - .into_iter() - .flatten() - .any(|content| { - content.get("text").and_then(Value::as_str) == Some("steer via realtime") - }) - .then_some(()), + payload: RealtimeEvent::HandoffRequested(handoff), + }) if handoff.input_transcript == "steer via realtime" => Some(()), _ => None, }) .await; @@ -1141,7 +1404,7 @@ async fn inbound_realtime_text_steers_active_turn() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn inbound_spawn_transcript_starts_turn_and_does_not_block_realtime_audio() -> Result<()> { +async fn inbound_handoff_request_starts_turn_and_does_not_block_realtime_audio() -> Result<()> { skip_if_no_network!(Ok(())); let (gate_completed_tx, gate_completed_rx) = oneshot::channel(); @@ -1157,33 +1420,24 @@ async fn inbound_spawn_transcript_starts_turn_and_does_not_block_realtime_audio( ]; let (api_server, completions) = start_streaming_sse_server(vec![first_chunks]).await; - let delegated_text = "delegate from spawn transcript"; + let delegated_text = "delegate from handoff request"; let realtime_server = start_websocket_server(vec![vec![vec![ json!({ - "type": "session.created", - "session": { "id": "sess_spawn_transcript" } + "type": "session.updated", + "session": { "id": "sess_handoff_request", "instructions": "backend prompt" } }), json!({ - "type": "conversation.item.added", - "item": { - "type": "spawn_transcript", - "seq": 1, - "full_user_transcript": delegated_text, - "delta_user_transcript": delegated_text, - "backend_prompt_messages": [{ - "role": "user", - "channel": null, - "content": delegated_text, - "content_type": "text" - }], - "transcript_source": "backend_prompt_messages" - } + "type": "conversation.handoff.requested", + "handoff_id": "handoff_audio", + "item_id": "item_audio", + "input_transcript": delegated_text, + "messages": [{ "role": "user", "text": delegated_text }] }), json!({ - "type": "response.output_audio.delta", + "type": "conversation.output_audio.delta", "delta": "AQID", "sample_rate": 24000, - "num_channels": 1 + "channels": 1 }), ]]]) .await; @@ -1205,18 +1459,17 @@ async fn inbound_spawn_transcript_starts_turn_and_does_not_block_realtime_audio( let _ = wait_for_event_match(&test.codex, |msg| match msg { EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { - payload: RealtimeEvent::SessionCreated { session_id }, - }) if session_id == "sess_spawn_transcript" => Some(()), + payload: RealtimeEvent::SessionUpdated { session_id, .. }, + }) if session_id == "sess_handoff_request" => Some(()), _ => None, }) .await; let _ = wait_for_event_match(&test.codex, |msg| match msg { EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { - payload: RealtimeEvent::ConversationItemAdded(item), - }) => (item.get("type").and_then(Value::as_str) == Some("spawn_transcript") - && item.get("delta_user_transcript").and_then(Value::as_str) == Some(delegated_text)) - .then_some(()), + payload: RealtimeEvent::HandoffRequested(handoff), + }) => (handoff.handoff_id == "handoff_audio" && handoff.input_transcript == delegated_text) + .then_some(()), _ => None, }) .await; @@ -1231,7 +1484,7 @@ async fn inbound_spawn_transcript_starts_turn_and_does_not_block_realtime_audio( }), ) .await - .expect("timed out waiting for realtime audio after spawn_transcript"); + .expect("timed out waiting for realtime audio after handoff request"); assert_eq!(audio_out.data, "AQID"); let completion = completions diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index c304cc25fa..63f15f9c0d 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -177,6 +177,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( model: requested_model.to_string(), effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -238,6 +239,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() .model_reasoning_summary .unwrap_or(ReasoningSummary::Auto), ), + service_tier: None, collaboration_mode: None, personality: None, }) @@ -355,6 +357,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { model: Some(REMOTE_MODEL_SLUG.to_string()), effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -392,6 +395,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { model: REMOTE_MODEL_SLUG.to_string(), effort: None, summary: Some(ReasoningSummary::Auto), + service_tier: None, collaboration_mode: None, personality: None, }) @@ -587,6 +591,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { model: Some(model.to_string()), effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -605,6 +610,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { model: model.to_string(), effort: None, summary: Some(ReasoningSummary::Auto), + service_tier: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index a6381de2d2..6f898e8c03 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -135,6 +135,7 @@ async fn submit_turn( model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 1f20f5dfdf..f767234c89 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -138,6 +138,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: Some(CollaborationMode { mode, settings: Settings { @@ -254,6 +255,7 @@ where model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, }) diff --git a/codex-rs/core/tests/suite/resume.rs b/codex-rs/core/tests/suite/resume.rs index 3d75879e49..98a570a1d9 100644 --- a/codex-rs/core/tests/suite/resume.rs +++ b/codex-rs/core/tests/suite/resume.rs @@ -340,6 +340,7 @@ async fn resume_model_switch_is_not_duplicated_after_pre_turn_override() -> Resu model: Some("gpt-5.1-codex-max".to_string()), effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index d4b8ba80a4..3a18cf157b 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -830,6 +830,7 @@ async fn review_uses_overridden_cwd_for_base_branch_merge_base() { model: None, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index dfbac85a09..06c0042985 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -130,6 +130,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -298,6 +299,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -498,6 +500,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re model: text_only_model_slug.to_string(), effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -611,6 +614,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -771,6 +775,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -991,6 +996,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 263e4b96dc..eabbdc9d03 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -49,6 +49,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> model: REQUESTED_MODEL.to_string(), effort: test.config.model_reasoning_effort, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -146,6 +147,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request model: REQUESTED_MODEL.to_string(), effort: test.config.model_reasoning_effort, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -230,6 +232,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul model: REQUESTED_MODEL.to_string(), effort: test.config.model_reasoning_effort, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -278,6 +281,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> model: REQUESTED_MODEL.to_string(), effort: test.config.model_reasoning_effort, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index a6f8ada10f..59c13fa50d 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -162,6 +162,7 @@ async fn run_snapshot_command_with_options( model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -248,6 +249,7 @@ async fn run_shell_command_snapshot_with_options( model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -317,6 +319,7 @@ async fn run_tool_turn_on_harness( model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -535,6 +538,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index 84e1d24085..d81cbb5c66 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -67,6 +67,7 @@ async fn submit_turn_with_policies( model: test.session_configured.model.clone(), effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -404,6 +405,140 @@ async fn shell_zsh_fork_skill_without_permissions_inherits_turn_sandbox() -> Res Ok(()) } +/// Empty skill permissions should behave like no skill override and inherit the +/// turn sandbox instead of forcing an explicit read-only skill sandbox. +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn shell_zsh_fork_skill_with_empty_permissions_inherits_turn_sandbox() -> Result<()> { + skip_if_no_network!(Ok(())); + + let Some(runtime) = zsh_fork_runtime("zsh-fork empty skill permissions test")? else { + return Ok(()); + }; + + let outside_dir = tempfile::tempdir_in(std::env::current_dir()?)?; + let outside_path = outside_dir + .path() + .join("zsh-fork-skill-empty-permissions.txt"); + let outside_path_quoted = shlex::try_join([outside_path.to_string_lossy().as_ref()])?; + let script_contents = format!( + "#!/bin/sh\nprintf '%s' allowed > {outside_path_quoted}\ncat {outside_path_quoted}\n" + ); + let outside_path_for_hook = outside_path.clone(); + let script_contents_for_hook = script_contents.clone(); + + let server = start_mock_server().await; + let test = build_zsh_fork_test( + &server, + runtime, + AskForApproval::OnRequest, + SandboxPolicy::DangerFullAccess, + move |home| { + let _ = fs::remove_file(&outside_path_for_hook); + write_skill_with_shell_script_contents( + home, + "mbolin-test-skill", + "sandboxed.sh", + &script_contents_for_hook, + ) + .unwrap(); + write_skill_metadata(home, "mbolin-test-skill", "permissions: {}\n").unwrap(); + }, + ) + .await?; + + let (script_path_str, command) = skill_script_command(&test, "sandboxed.sh")?; + + let first_call_id = "zsh-fork-skill-empty-permissions-1"; + let first_arguments = shell_command_arguments(&command)?; + let first_mocks = mount_function_call_agent_response( + &server, + first_call_id, + &first_arguments, + "shell_command", + ) + .await; + + submit_turn_with_policies( + &test, + "use $mbolin-test-skill", + AskForApproval::OnRequest, + SandboxPolicy::DangerFullAccess, + ) + .await?; + + let approval = wait_for_exec_approval_request(&test) + .await + .expect("expected exec approval request before completion"); + assert_eq!(approval.call_id, first_call_id); + assert_eq!(approval.command, vec![script_path_str.clone()]); + assert_eq!(approval.additional_permissions, None); + + test.codex + .submit(Op::ExecApproval { + id: approval.effective_approval_id(), + turn_id: None, + decision: ReviewDecision::ApprovedForSession, + }) + .await?; + + wait_for_turn_complete(&test).await; + + let first_output = first_mocks + .completion + .single_request() + .function_call_output(first_call_id)["output"] + .as_str() + .unwrap_or_default() + .to_string(); + assert!( + first_output.contains("allowed"), + "expected empty skill permissions to inherit full-access turn sandbox, got output: {first_output:?}" + ); + assert_eq!(fs::read_to_string(&outside_path)?, "allowed"); + + let second_call_id = "zsh-fork-skill-empty-permissions-2"; + let second_arguments = shell_command_arguments(&command)?; + let second_mocks = mount_function_call_agent_response( + &server, + second_call_id, + &second_arguments, + "shell_command", + ) + .await; + + let _ = fs::remove_file(&outside_path); + + submit_turn_with_policies( + &test, + "use $mbolin-test-skill", + AskForApproval::OnRequest, + SandboxPolicy::DangerFullAccess, + ) + .await?; + + let cached_approval = wait_for_exec_approval_request(&test).await; + assert!( + cached_approval.is_none(), + "expected second run to reuse the cached session approval" + ); + + let second_output = second_mocks + .completion + .single_request() + .function_call_output(second_call_id)["output"] + .as_str() + .unwrap_or_default() + .to_string(); + assert!( + second_output.contains("allowed"), + "expected cached empty-permissions skill approval to inherit the turn sandbox, got output: {second_output:?}" + ); + assert_eq!(fs::read_to_string(&outside_path)?, "allowed"); + + Ok(()) +} + /// The validation to focus on is: writes to the skill-approved folder succeed, /// and writes to an unrelated folder fail, both before and after cached approval. #[cfg(unix)] diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 2f786590b1..801e0dd6bb 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -78,6 +78,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index 0b1a76aa48..177453d6dd 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -381,6 +381,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< model: test.session_configured.model.clone(), effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 13191892ce..ca666bce38 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -89,6 +89,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -158,6 +159,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -237,6 +239,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -328,6 +331,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -427,6 +431,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index 1678e58349..a177a6ee37 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -46,6 +46,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -361,6 +362,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 6ba6e53b1b..e7b25799ef 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -490,6 +490,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/turn_state.rs b/codex-rs/core/tests/suite/turn_state.rs index 62bd974393..c068cfafb1 100644 --- a/codex-rs/core/tests/suite/turn_state.rs +++ b/codex-rs/core/tests/suite/turn_state.rs @@ -4,7 +4,6 @@ use anyhow::Result; use core_test_support::responses::WebSocketConnectionConfig; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; -use core_test_support::responses::ev_done; use core_test_support::responses::ev_reasoning_item; use core_test_support::responses::ev_response_created; use core_test_support::responses::ev_shell_command_call; @@ -100,7 +99,7 @@ async fn websocket_turn_state_persists_within_turn_and_resets_after() -> Result< ev_response_created("resp-1"), ev_reasoning_item("rsn-1", &["thinking"], &[]), ev_shell_command_call(call_id, "echo websocket"), - ev_done(), + ev_completed("resp-1"), ]], response_headers: vec![(TURN_STATE_HEADER.to_string(), "ts-1".to_string())], accept_delay: None, diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 55bc19e680..a3ef5711f8 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -209,6 +209,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -338,6 +339,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -416,6 +418,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -497,6 +500,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -590,6 +594,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> { model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -665,6 +670,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -741,6 +747,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -871,6 +878,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -1008,6 +1016,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -1168,6 +1177,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -1266,6 +1276,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -1384,6 +1395,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> { model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -1474,6 +1486,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> { model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -1555,6 +1568,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -1686,6 +1700,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -1854,6 +1869,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -1931,6 +1947,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -2019,6 +2036,7 @@ async fn unified_exec_interrupt_terminates_long_running_session() -> Result<()> model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -2116,6 +2134,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -2251,6 +2270,7 @@ PY model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -2365,6 +2385,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -2461,6 +2482,7 @@ PY model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -2543,6 +2565,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -2647,6 +2670,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -2742,6 +2766,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> { model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -2877,6 +2902,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index 766d79abb8..6ce43a2d13 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -178,6 +178,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> model: fixture.session_configured.model.clone(), effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 4521ed45f8..5b8b9e0888 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -113,6 +113,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -215,6 +216,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -345,6 +347,7 @@ console.log(out.output?.body?.text ?? ""); model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -434,6 +437,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -509,6 +513,7 @@ async fn view_image_tool_placeholder_for_non_image_files() -> anyhow::Result<()> model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -601,6 +606,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -725,6 +731,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an model: model_slug.to_string(), effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) @@ -801,6 +808,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> model: session_model, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index 9ff5cbe001..fadd427c16 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -66,9 +66,9 @@ async fn websocket_fallback_switches_to_http_on_upgrade_required_connect() -> Re .filter(|req| req.method == Method::POST && req.url.path().ends_with("/responses")) .count(); - // Startup prewarm now only preconnects for v1 (one websocket GET with no request body). - // The first turn then attempts websocket once, sees 426, and falls back to HTTP. - assert_eq!(websocket_attempts, 2); + // The startup prewarm request sees 426 and immediately switches the session to HTTP fallback, + // so the first turn goes straight to HTTP with no additional websocket connect attempt. + assert_eq!(websocket_attempts, 1); assert_eq!(http_attempts, 1); assert_eq!(response_mock.requests().len(), 1); @@ -161,6 +161,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result model: session_configured.model.clone(), effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/deny.toml b/codex-rs/deny.toml index 29bf6c5880..d2de502498 100644 --- a/codex-rs/deny.toml +++ b/codex-rs/deny.toml @@ -78,6 +78,7 @@ ignore = [ # TODO(fcoury): remove this exception when syntect drops yaml-rust and bincode, or updates to versions that have fixed the vulnerabilities. { id = "RUSTSEC-2024-0320", reason = "yaml-rust is unmaintained; pulled in via syntect v5.3.0 used by codex-tui for syntax highlighting; no fixed release yet" }, { id = "RUSTSEC-2025-0141", reason = "bincode is unmaintained; pulled in via syntect v5.3.0 used by codex-tui for syntax highlighting; no fixed release yet" }, + { id = "RUSTSEC-2025-0134", reason = "rustls-pemfile is unmaintained; pulled in via reqwest -> ppt-rs -> codex-artifact-presentation; no safe upgrade available yet" }, ] # If this is true, then cargo deny will use the git executable to fetch advisory database. # If this is false, then it uses a built-in git library. diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index c0a559e067..5f0ac4865e 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -275,6 +275,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result sandbox_mode, cwd: resolved_cwd, model_provider: model_provider.clone(), + service_tier: None, codex_linux_sandbox_exe: arg0_paths.codex_linux_sandbox_exe.clone(), main_execve_wrapper_exe: arg0_paths.main_execve_wrapper_exe.clone(), js_repl_node_path: None, @@ -567,6 +568,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result model: default_model, effort: default_effort, summary: None, + service_tier: None, final_output_json_schema: output_schema, collaboration_mode: None, personality: None, diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 6595e82eab..a051b5bb00 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -93,6 +93,7 @@ fn session_configured_produces_thread_started_event() { thread_name: None, model: "codex-mini-latest".to_string(), model_provider_id: "test-provider".to_string(), + service_tier: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/home/user/project"), diff --git a/codex-rs/file-search/src/lib.rs b/codex-rs/file-search/src/lib.rs index 151fafe98f..ba376c38f1 100644 --- a/codex-rs/file-search/src/lib.rs +++ b/codex-rs/file-search/src/lib.rs @@ -92,6 +92,13 @@ pub struct FileSearchOptions { pub exclude: Vec, pub threads: NonZero, pub compute_indices: bool, + /// Toggle ignore-file processing in the walker. + /// + /// When enabled, `.gitignore` files are scoped by + /// `WalkBuilder::require_git(true)`, so they are honored only when the + /// traversed path is inside a git repository. When disabled, the walker + /// turns off `.gitignore`, git-global/exclude rules, `.ignore`, and + /// parent-directory ignore scanning. pub respect_gitignore: bool, } @@ -379,6 +386,18 @@ fn get_file_path<'a>(path: &'a Path, search_directories: &[PathBuf]) -> Option<( rel_path.to_str().map(|p| (root_idx, p)) } +/// Walks the search directories and feeds discovered file paths into `nucleo` +/// via the injector. +/// +/// The walker uses `require_git(true)` to match git's own ignore semantics: +/// git never reads `.gitignore` files from directories above the repository +/// root. Without this flag, the `ignore` crate reads `.gitignore` files from +/// *all* ancestor directories—a deliberate divergence from git intended for +/// non-git use cases—allowing a broad parent ignore (e.g. `~/.gitignore` +/// containing `*`) to silently suppress every file in the walk. +/// +/// When `respect_gitignore` is `false`, all git-related ignore processing is +/// disabled regardless of this flag. fn walker_worker( inner: Arc, override_matcher: Option, @@ -399,8 +418,9 @@ fn walker_worker( .hidden(false) // Follow symlinks to search their contents. .follow_links(true) - // Don't require git to be present to apply to apply git-related ignore rules. - .require_git(false); + // Keep ignore behavior aligned with git repositories: only apply + // gitignore rules when a git context exists. + .require_git(true); if !inner.respect_gitignore { walk_builder .git_ignore(false) @@ -966,4 +986,151 @@ mod tests { assert_eq!(results.matches, Vec::new()); assert_eq!(results.total_match_count, 0); } + + /// Regression test for #3493: a parent directory's `.gitignore` with `*` + /// must not suppress files discovered inside a child "repo" directory. + /// + /// The fixture intentionally omits `git init` so that no `.git` directory + /// exists. With `require_git(true)`, the walker skips all gitignore + /// processing, making the parent's broad ignore harmless. + #[test] + fn parent_gitignore_outside_repo_does_not_hide_repo_files() { + let temp = tempfile::tempdir().unwrap(); + let parent = temp.path().join("home"); + let repo = parent.join("repo"); + fs::create_dir_all(repo.join(".vscode")).unwrap(); + + fs::write(parent.join(".gitignore"), "*\n!.gitignore\n").unwrap(); + fs::write( + repo.join(".gitignore"), + ".vscode/*\n!.vscode/\n!.vscode/settings.json\n!package.json\n", + ) + .unwrap(); + fs::write(repo.join("package.json"), "{ \"name\": \"demo\" }\n").unwrap(); + fs::write(repo.join(".vscode/settings.json"), "{ \"editor\": true }\n").unwrap(); + + let respect_results = run( + "package", + vec![repo.clone()], + FileSearchOptions { + limit: NonZero::new(20).unwrap(), + exclude: Vec::new(), + threads: NonZero::new(2).unwrap(), + compute_indices: false, + respect_gitignore: true, + }, + None, + ) + .expect("run ok"); + assert!( + respect_results + .matches + .iter() + .any(|m| m.path.as_path() == Path::new("package.json")) + ); + + let nested_file_results = run( + "settings", + vec![repo], + FileSearchOptions { + limit: NonZero::new(20).unwrap(), + exclude: Vec::new(), + threads: NonZero::new(2).unwrap(), + compute_indices: false, + respect_gitignore: true, + }, + None, + ) + .expect("run ok"); + assert!( + nested_file_results + .matches + .iter() + .any(|m| m.path.as_path() == Path::new(".vscode/settings.json")) + ); + } + + #[test] + fn git_repo_still_respects_local_gitignore_when_enabled() { + let temp = tempfile::tempdir().unwrap(); + let parent = temp.path().join("home"); + let repo = parent.join("repo"); + fs::create_dir_all(repo.join(".vscode")).unwrap(); + + fs::write(parent.join(".gitignore"), "*\n!.gitignore\n").unwrap(); + fs::write( + repo.join(".gitignore"), + ".vscode/*\n!.vscode/\n!.vscode/settings.json\n!package.json\n", + ) + .unwrap(); + fs::write(repo.join("package.json"), "{ \"name\": \"demo\" }\n").unwrap(); + fs::write(repo.join(".vscode/settings.json"), "{ \"editor\": true }\n").unwrap(); + fs::write( + repo.join(".vscode/extensions.json"), + "{ \"extensions\": [] }\n", + ) + .unwrap(); + + fs::create_dir_all(repo.join(".git")).unwrap(); + + let package_results = run( + "package", + vec![repo.clone()], + FileSearchOptions { + limit: NonZero::new(20).unwrap(), + exclude: Vec::new(), + threads: NonZero::new(2).unwrap(), + compute_indices: false, + respect_gitignore: true, + }, + None, + ) + .expect("run ok"); + assert!( + package_results + .matches + .iter() + .any(|m| m.path.as_path() == Path::new("package.json")) + ); + + let ignored_results = run( + "extensions.json", + vec![repo.clone()], + FileSearchOptions { + limit: NonZero::new(20).unwrap(), + exclude: Vec::new(), + threads: NonZero::new(2).unwrap(), + compute_indices: false, + respect_gitignore: true, + }, + None, + ) + .expect("run ok"); + assert!( + !ignored_results + .matches + .iter() + .any(|m| m.path.as_path() == Path::new(".vscode/extensions.json")) + ); + + let whitelisted_results = run( + "settings.json", + vec![repo], + FileSearchOptions { + limit: NonZero::new(20).unwrap(), + exclude: Vec::new(), + threads: NonZero::new(2).unwrap(), + compute_indices: false, + respect_gitignore: true, + }, + None, + ) + .expect("run ok"); + assert!( + whitelisted_results + .matches + .iter() + .any(|m| m.path.as_path() == Path::new(".vscode/settings.json")) + ); + } } diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index 7dcfa0c233..684cd1a705 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -300,6 +300,7 @@ mod tests { thread_name: None, model: "gpt-4o".to_string(), model_provider_id: "test-provider".to_string(), + service_tier: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/home/user/project"), @@ -342,6 +343,7 @@ mod tests { thread_name: None, model: "gpt-4o".to_string(), model_provider_id: "test-provider".to_string(), + service_tier: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/home/user/project"), @@ -408,6 +410,7 @@ mod tests { thread_name: None, model: "gpt-4o".to_string(), model_provider_id: "test-provider".to_string(), + service_tier: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/home/user/project"), diff --git a/codex-rs/otel/src/lib.rs b/codex-rs/otel/src/lib.rs index 5aaee5f5a9..217099524c 100644 --- a/codex-rs/otel/src/lib.rs +++ b/codex-rs/otel/src/lib.rs @@ -1,6 +1,7 @@ pub mod config; pub mod metrics; pub mod otel_provider; +pub mod trace_context; pub mod traces; mod otlp; @@ -23,6 +24,11 @@ use tracing::debug; pub use crate::metrics::runtime_metrics::RuntimeMetricTotals; pub use crate::metrics::runtime_metrics::RuntimeMetricsSummary; +pub use crate::otel_provider::traceparent_context_from_env; +pub use crate::trace_context::context_from_w3c_trace_context; +pub use crate::trace_context::current_span_w3c_trace_context; +pub use crate::trace_context::set_parent_from_context; +pub use crate::trace_context::set_parent_from_w3c_trace_context; #[derive(Debug, Clone, Serialize, Display)] #[serde(rename_all = "snake_case")] diff --git a/codex-rs/otel/src/otel_provider.rs b/codex-rs/otel/src/otel_provider.rs index 6f2dc5a09d..c9233f5d6a 100644 --- a/codex-rs/otel/src/otel_provider.rs +++ b/codex-rs/otel/src/otel_provider.rs @@ -3,13 +3,12 @@ use crate::config::OtelHttpProtocol; use crate::config::OtelSettings; use crate::metrics::MetricsClient; use crate::metrics::MetricsConfig; +use crate::trace_context::context_from_trace_headers; use gethostname::gethostname; use opentelemetry::Context; use opentelemetry::KeyValue; use opentelemetry::context::ContextGuard; use opentelemetry::global; -use opentelemetry::propagation::TextMapPropagator; -use opentelemetry::trace::TraceContextExt; use opentelemetry::trace::TracerProvider as _; use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge; use opentelemetry_otlp::LogExporter; @@ -30,7 +29,6 @@ use opentelemetry_sdk::trace::SdkTracerProvider; use opentelemetry_sdk::trace::Tracer; use opentelemetry_semantic_conventions as semconv; use std::cell::RefCell; -use std::collections::HashMap; use std::env; use std::error::Error; use std::sync::OnceLock; @@ -172,7 +170,7 @@ impl Drop for OtelProvider { } } -pub(crate) fn traceparent_context_from_env() -> Option { +pub fn traceparent_context_from_env() -> Option { TRACEPARENT_CONTEXT .get_or_init(load_traceparent_context) .clone() @@ -194,7 +192,7 @@ fn load_traceparent_context() -> Option { let traceparent = env::var(TRACEPARENT_ENV_VAR).ok()?; let tracestate = env::var(TRACESTATE_ENV_VAR).ok(); - match extract_traceparent_context(traceparent, tracestate) { + match context_from_trace_headers(Some(&traceparent), tracestate.as_deref()) { Some(context) => { debug!("TRACEPARENT detected; continuing trace from parent context"); Some(context) @@ -206,22 +204,6 @@ fn load_traceparent_context() -> Option { } } -fn extract_traceparent_context(traceparent: String, tracestate: Option) -> Option { - let mut headers = HashMap::new(); - headers.insert("traceparent".to_string(), traceparent); - if let Some(tracestate) = tracestate { - headers.insert("tracestate".to_string(), tracestate); - } - - let context = TraceContextPropagator::new().extract(&headers); - let span = context.span(); - let span_context = span.span_context(); - if !span_context.is_valid() { - return None; - } - Some(context) -} - fn make_resource(settings: &OtelSettings) -> Resource { Resource::builder() .with_service_name(settings.service_name.clone()) @@ -407,8 +389,9 @@ mod tests { fn parses_valid_traceparent() { let trace_id = "00000000000000000000000000000001"; let span_id = "0000000000000002"; - let context = extract_traceparent_context(format!("00-{trace_id}-{span_id}-01"), None) - .expect("trace context"); + let context = + context_from_trace_headers(Some(&format!("00-{trace_id}-{span_id}-01")), None) + .expect("trace context"); let span = context.span(); let span_context = span.span_context(); assert_eq!( @@ -421,7 +404,7 @@ mod tests { #[test] fn invalid_traceparent_returns_none() { - assert!(extract_traceparent_context("not-a-traceparent".to_string(), None).is_none()); + assert!(context_from_trace_headers(Some("not-a-traceparent"), None).is_none()); } #[test] diff --git a/codex-rs/otel/src/trace_context.rs b/codex-rs/otel/src/trace_context.rs new file mode 100644 index 0000000000..3ef38062d3 --- /dev/null +++ b/codex-rs/otel/src/trace_context.rs @@ -0,0 +1,106 @@ +use std::collections::HashMap; + +use codex_protocol::protocol::W3cTraceContext; +use opentelemetry::Context; +use opentelemetry::propagation::TextMapPropagator; +use opentelemetry::trace::TraceContextExt; +use opentelemetry_sdk::propagation::TraceContextPropagator; +use tracing::Span; +use tracing_opentelemetry::OpenTelemetrySpanExt; + +pub fn current_span_w3c_trace_context() -> Option { + let context = Span::current().context(); + if !context.span().span_context().is_valid() { + return None; + } + + let mut headers = HashMap::new(); + TraceContextPropagator::new().inject_context(&context, &mut headers); + + Some(W3cTraceContext { + traceparent: headers.remove("traceparent"), + tracestate: headers.remove("tracestate"), + }) +} + +pub fn context_from_w3c_trace_context(trace: &W3cTraceContext) -> Option { + context_from_trace_headers(trace.traceparent.as_deref(), trace.tracestate.as_deref()) +} + +pub fn set_parent_from_w3c_trace_context(span: &Span, trace: &W3cTraceContext) -> bool { + if let Some(context) = context_from_w3c_trace_context(trace) { + set_parent_from_context(span, context); + true + } else { + false + } +} + +pub fn set_parent_from_context(span: &Span, context: Context) { + let _ = span.set_parent(context); +} + +pub(crate) fn context_from_trace_headers( + traceparent: Option<&str>, + tracestate: Option<&str>, +) -> Option { + let traceparent = traceparent?; + let mut headers = HashMap::new(); + headers.insert("traceparent".to_string(), traceparent.to_string()); + if let Some(tracestate) = tracestate { + headers.insert("tracestate".to_string(), tracestate.to_string()); + } + + let context = TraceContextPropagator::new().extract(&headers); + if !context.span().span_context().is_valid() { + return None; + } + Some(context) +} + +#[cfg(test)] +mod tests { + use super::context_from_trace_headers; + use super::context_from_w3c_trace_context; + use codex_protocol::protocol::W3cTraceContext; + use opentelemetry::trace::SpanId; + use opentelemetry::trace::TraceContextExt; + use opentelemetry::trace::TraceId; + use pretty_assertions::assert_eq; + + #[test] + fn parses_valid_w3c_trace_context() { + let trace_id = "00000000000000000000000000000001"; + let span_id = "0000000000000002"; + let context = context_from_w3c_trace_context(&W3cTraceContext { + traceparent: Some(format!("00-{trace_id}-{span_id}-01")), + tracestate: None, + }) + .expect("trace context"); + + let span = context.span(); + let span_context = span.span_context(); + assert_eq!( + span_context.trace_id(), + TraceId::from_hex(trace_id).unwrap() + ); + assert_eq!(span_context.span_id(), SpanId::from_hex(span_id).unwrap()); + assert!(span_context.is_remote()); + } + + #[test] + fn invalid_traceparent_returns_none() { + assert!(context_from_trace_headers(Some("not-a-traceparent"), None).is_none()); + } + + #[test] + fn missing_traceparent_returns_none() { + assert!( + context_from_w3c_trace_context(&W3cTraceContext { + traceparent: None, + tracestate: Some("vendor=value".to_string()), + }) + .is_none() + ); + } +} diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index 7586212adb..984dcf39f1 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -113,6 +113,13 @@ pub enum WebSearchMode { Live, } +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum ServiceTier { + Fast, +} + #[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS)] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] diff --git a/codex-rs/protocol/src/prompts/realtime/realtime_end.md b/codex-rs/protocol/src/prompts/realtime/realtime_end.md index 07ccae5286..abec16da03 100644 --- a/codex-rs/protocol/src/prompts/realtime/realtime_end.md +++ b/codex-rs/protocol/src/prompts/realtime/realtime_end.md @@ -1,5 +1,3 @@ Realtime conversation ended. -You are still operating behind an intermediary rather than speaking to the user directly. Use the conversation transcript and current context to decide whether backend help is actually needed, and avoid verbose responses that only add latency. - -Subsequent user input may return to typed text rather than transcript-style text. Do not assume recognition errors or missing punctuation once realtime has ended. Resume normal chat behavior. +Subsequent user input will return to typed text rather than transcript-style text. Do not assume recognition errors or missing punctuation once realtime has ended. Resume normal chat behavior. diff --git a/codex-rs/protocol/src/prompts/realtime/realtime_start.md b/codex-rs/protocol/src/prompts/realtime/realtime_start.md index c7d363eff7..3159a2b07f 100644 --- a/codex-rs/protocol/src/prompts/realtime/realtime_start.md +++ b/codex-rs/protocol/src/prompts/realtime/realtime_start.md @@ -6,5 +6,4 @@ When invoked, you receive the latest conversation transcript and any relevant mo When user text is routed from realtime, treat it as a transcript. It may be unpunctuated or contain recognition errors. -- Ask brief clarification questions when needed. -- Keep responses concise and action-oriented. +- Keep responses concise and action-oriented. Your updates should help the intermediary respond to the user. diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 3e9e9fe9a3..3f1c392719 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -18,6 +18,7 @@ use crate::config_types::CollaborationMode; use crate::config_types::ModeKind; use crate::config_types::Personality; use crate::config_types::ReasoningSummary as ReasoningSummaryConfig; +use crate::config_types::ServiceTier; use crate::config_types::WindowsSandboxLevel; use crate::custom_prompts::CustomPrompt; use crate::dynamic_tools::DynamicToolCallOutputContentItem; @@ -83,6 +84,16 @@ pub struct Submission { pub op: Op, } +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct W3cTraceContext { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub traceparent: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub tracestate: Option, +} + /// Config payload for refreshing MCP servers. #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)] pub struct McpServerRefreshConfig { @@ -106,12 +117,32 @@ pub struct RealtimeAudioFrame { pub samples_per_channel: Option, } +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct RealtimeHandoffMessage { + pub role: String, + pub text: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct RealtimeHandoffRequested { + pub handoff_id: String, + pub item_id: String, + pub input_transcript: String, + pub messages: Vec, +} + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] pub enum RealtimeEvent { - SessionCreated { session_id: String }, - SessionUpdated { backend_prompt: Option }, + SessionUpdated { + session_id: String, + instructions: Option, + }, AudioOut(RealtimeAudioFrame), ConversationItemAdded(Value), + ConversationItemDone { + item_id: String, + }, + HandoffRequested(RealtimeHandoffRequested), Error(String), } @@ -192,6 +223,15 @@ pub enum Op { /// fall back to the selected model's default on new sessions). #[serde(default, skip_serializing_if = "Option::is_none")] summary: Option, + + /// Optional service tier override for this turn. + /// + /// Use `Some(Some(_))` to set a specific tier for this turn, `Some(None)` to + /// explicitly clear the tier for this turn, or `None` to keep the existing + /// session preference. + #[serde(default, skip_serializing_if = "Option::is_none")] + service_tier: Option>, + // The JSON schema to use for the final assistant message final_output_json_schema: Option, @@ -244,6 +284,13 @@ pub enum Op { #[serde(skip_serializing_if = "Option::is_none")] summary: Option, + /// Updated service tier preference for future turns. + /// + /// Use `Some(Some(_))` to set a specific tier, `Some(None)` to clear the + /// preference, or `None` to leave the existing value unchanged. + #[serde(skip_serializing_if = "Option::is_none")] + service_tier: Option>, + /// EXPERIMENTAL - set a pre-set collaboration mode. /// Takes precedence over model, effort, and developer instructions if set. #[serde(skip_serializing_if = "Option::is_none")] @@ -2736,6 +2783,9 @@ pub struct SessionConfiguredEvent { pub model_provider_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub service_tier: Option, + /// When to escalate for approval for execution pub approval_policy: AskForApproval, @@ -3417,6 +3467,7 @@ mod tests { thread_name: None, model: "codex-mini-latest".to_string(), model_provider_id: "openai".to_string(), + service_tier: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/home/user/project"), diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 10e758657e..2cba7f4f11 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1245,6 +1245,15 @@ impl App { } } + let has_non_primary_agent_thread = self + .agent_picker_threads + .keys() + .any(|thread_id| Some(*thread_id) != self.primary_thread_id); + if !self.config.features.enabled(Feature::Collab) && !has_non_primary_agent_thread { + self.chat_widget.open_multi_agent_enable_prompt(); + return; + } + if self.agent_picker_threads.is_empty() { self.chat_widget .add_info_message("No agents available yet.".to_string(), None); @@ -1410,6 +1419,7 @@ impl App { // Start a fresh in-memory session while preserving resumability via persisted rollout // history. let model = self.chat_widget.current_model().to_string(); + let config = self.fresh_session_config(); let summary = session_summary( self.chat_widget.token_usage(), self.chat_widget.thread_id(), @@ -1420,7 +1430,7 @@ impl App { tracing::warn!(error = %err, "failed to close all threads"); } let init = crate::chatwidget::ChatWidgetInit { - config: self.config.clone(), + config, frame_requester: tui.frame_requester(), app_event_tx: self.app_event_tx.clone(), // New sessions start without prefilled message content. @@ -1449,6 +1459,12 @@ impl App { tui.frame_requester().schedule_frame(); } + fn fresh_session_config(&self) -> Config { + let mut config = self.config.clone(); + config.service_tier = self.chat_widget.current_service_tier(); + config + } + async fn drain_active_thread_events(&mut self, tui: &mut tui::Tui) -> Result<()> { let Some(mut rx) = self.active_thread_rx.take() else { return Ok(()); @@ -2575,6 +2591,7 @@ impl App { model: None, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }, @@ -2597,6 +2614,7 @@ impl App { model: None, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, }, @@ -2708,6 +2726,39 @@ impl App { } } } + AppEvent::PersistServiceTierSelection { service_tier } => { + self.refresh_status_line(); + let profile = self.active_profile.as_deref(); + match ConfigEditsBuilder::new(&self.config.codex_home) + .with_profile(profile) + .set_service_tier(service_tier) + .apply() + .await + { + Ok(()) => { + let status = if service_tier.is_some() { "on" } else { "off" }; + let mut message = format!("Fast mode set to {status}"); + if let Some(profile) = profile { + message.push_str(" for "); + message.push_str(profile); + message.push_str(" profile"); + } + self.chat_widget.add_info_message(message, None); + } + Err(err) => { + tracing::error!(error = %err, "failed to persist fast mode selection"); + if let Some(profile) = profile { + self.chat_widget.add_error_message(format!( + "Failed to save Fast mode for profile `{profile}`: {err}" + )); + } else { + self.chat_widget.add_error_message(format!( + "Failed to save default Fast mode: {err}" + )); + } + } + } + } AppEvent::PersistRealtimeAudioDeviceSelection { kind, name } => { let builder = match kind { RealtimeAudioDeviceKind::Microphone => { @@ -2870,6 +2921,7 @@ impl App { model: None, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, })); @@ -3353,6 +3405,7 @@ impl App { thread_name: None, model: config_snapshot.model, model_provider_id: config_snapshot.model_provider_id, + service_tier: config_snapshot.service_tier, approval_policy: config_snapshot.approval_policy, sandbox_policy: config_snapshot.sandbox_policy, cwd: config_snapshot.cwd, @@ -3653,6 +3706,7 @@ mod tests { use crate::history_cell::HistoryCell; use crate::history_cell::UserHistoryCell; use crate::history_cell::new_session_info; + use assert_matches::assert_matches; use codex_core::CodexAuth; use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; @@ -3813,6 +3867,7 @@ mod tests { thread_name: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), + service_tier: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/tmp/project"), @@ -4018,6 +4073,51 @@ mod tests { Ok(()) } + #[tokio::test] + async fn open_agent_picker_prompts_to_enable_multi_agent_when_disabled() -> Result<()> { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + + app.open_agent_picker().await; + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_matches!( + app_event_rx.try_recv(), + Ok(AppEvent::UpdateFeatureFlags { updates }) if updates == vec![(Feature::Collab, true)] + ); + let cell = match app_event_rx.try_recv() { + Ok(AppEvent::InsertHistoryCell(cell)) => cell, + other => panic!("expected InsertHistoryCell event, got {other:?}"), + }; + let rendered = cell + .display_lines(120) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + assert!(rendered.contains("Multi-agent will be enabled in the next session.")); + Ok(()) + } + + #[tokio::test] + async fn open_agent_picker_allows_existing_agent_threads_when_feature_is_disabled() -> Result<()> + { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + app.thread_event_channels + .insert(thread_id, ThreadEventChannel::new(1)); + + app.open_agent_picker().await; + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_matches!( + app_event_rx.try_recv(), + Ok(AppEvent::SelectAgentThread(selected_thread_id)) if selected_thread_id == thread_id + ); + Ok(()) + } + #[tokio::test] async fn refresh_pending_thread_approvals_only_lists_inactive_threads() { let mut app = make_test_app().await; @@ -4100,6 +4200,7 @@ mod tests { thread_name: None, model: "gpt-5".to_string(), model_provider_id: "test-provider".to_string(), + service_tier: None, approval_policy: AskForApproval::OnRequest, sandbox_policy: SandboxPolicy::new_workspace_write_policy(), cwd: PathBuf::from("/tmp/agent"), @@ -4320,6 +4421,7 @@ mod tests { thread_name: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), + service_tier: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/tmp/project"), @@ -4913,6 +5015,20 @@ mod tests { ); } + #[tokio::test] + async fn fresh_session_config_uses_current_service_tier() { + let mut app = make_test_app().await; + app.chat_widget + .set_service_tier(Some(codex_protocol::config_types::ServiceTier::Fast)); + + let config = app.fresh_session_config(); + + assert_eq!( + config.service_tier, + Some(codex_protocol::config_types::ServiceTier::Fast) + ); + } + #[tokio::test] async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; @@ -4943,6 +5059,7 @@ mod tests { thread_name: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), + service_tier: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/home/user/project"), @@ -5000,6 +5117,7 @@ mod tests { thread_name: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), + service_tier: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/home/user/project"), @@ -5091,6 +5209,7 @@ mod tests { thread_name: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), + service_tier: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/home/user/project"), @@ -5155,6 +5274,7 @@ mod tests { thread_name: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), + service_tier: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/home/user/project"), @@ -5234,6 +5354,7 @@ mod tests { thread_name: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), + service_tier: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/home/user/project"), @@ -5360,6 +5481,7 @@ mod tests { thread_name: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), + service_tier: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/home/user/project"), @@ -5428,6 +5550,7 @@ mod tests { thread_name: Some("keep me".to_string()), model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), + service_tier: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/tmp/project"), diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 45fa55e41b..7cf10a7c9c 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -25,6 +25,7 @@ use crate::history_cell::HistoryCell; use codex_core::features::Feature; use codex_protocol::config_types::CollaborationModeMask; use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; @@ -204,6 +205,11 @@ pub(crate) enum AppEvent { personality: Personality, }, + /// Persist the selected service tier to the appropriate config. + PersistServiceTierSelection { + service_tier: Option, + }, + /// Open the device picker for a realtime microphone or speaker. OpenRealtimeAudioDeviceSelection { kind: RealtimeAudioDeviceKind, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 5ea67d8c32..c084650660 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -43,6 +43,11 @@ //! - Prunes local attached images so only placeholders that survive expansion are sent. //! - Preserves remote image URLs as separate attachments even when text is empty. //! +//! When these paths clear the visible textarea after a successful submit or slash-command +//! dispatch, they intentionally preserve the textarea kill buffer. That lets users `Ctrl+K` part +//! of a draft, perform a composer action such as changing reasoning level, and then `Ctrl+Y` the +//! killed text back into the now-empty draft. +//! //! The numeric auto-submit path used by the slash popup performs the same pending-paste expansion //! and attachment pruning, and clears pending paste state on success. //! Slash commands with arguments (like `/plan` and `/review`) reuse the same preparation path so @@ -173,6 +178,7 @@ use super::paste_burst::PasteBurst; use super::skill_popup::MentionItem; use super::skill_popup::SkillPopup; use super::slash_commands; +use super::slash_commands::BuiltinCommandFlags; use crate::bottom_pane::paste_burst::FlushResult; use crate::bottom_pane::prompt_args::expand_custom_prompt; use crate::bottom_pane::prompt_args::expand_if_numeric_with_positional_args; @@ -393,6 +399,7 @@ pub(crate) struct ChatComposer { config: ChatComposerConfig, collaboration_mode_indicator: Option, connectors_enabled: bool, + fast_command_enabled: bool, personality_command_enabled: bool, realtime_conversation_enabled: bool, audio_device_selection_enabled: bool, @@ -424,6 +431,18 @@ enum ActivePopup { const FOOTER_SPACING_HEIGHT: u16 = 0; impl ChatComposer { + fn builtin_command_flags(&self) -> BuiltinCommandFlags { + BuiltinCommandFlags { + collaboration_modes_enabled: self.collaboration_modes_enabled, + connectors_enabled: self.connectors_enabled, + fast_command_enabled: self.fast_command_enabled, + personality_command_enabled: self.personality_command_enabled, + realtime_conversation_enabled: self.realtime_conversation_enabled, + audio_device_selection_enabled: self.audio_device_selection_enabled, + allow_elevate_sandbox: self.windows_degraded_sandbox_active, + } + } + pub fn new( has_input_focus: bool, app_event_tx: AppEventSender, @@ -499,6 +518,7 @@ impl ChatComposer { config, collaboration_mode_indicator: None, connectors_enabled: false, + fast_command_enabled: false, personality_command_enabled: false, realtime_conversation_enabled: false, audio_device_selection_enabled: false, @@ -564,6 +584,10 @@ impl ChatComposer { self.connectors_enabled = enabled; } + pub fn set_fast_command_enabled(&mut self, enabled: bool) { + self.fast_command_enabled = enabled; + } + pub fn set_collaboration_mode_indicator( &mut self, indicator: Option, @@ -2257,16 +2281,9 @@ impl ChatComposer { { let treat_as_plain_text = input_starts_with_space || name.contains('/'); if !treat_as_plain_text { - let is_builtin = slash_commands::find_builtin_command( - name, - self.collaboration_modes_enabled, - self.connectors_enabled, - self.personality_command_enabled, - self.realtime_conversation_enabled, - self.audio_device_selection_enabled, - self.windows_degraded_sandbox_active, - ) - .is_some(); + let is_builtin = + slash_commands::find_builtin_command(name, self.builtin_command_flags()) + .is_some(); let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:"); let is_known_prompt = name .strip_prefix(&prompt_prefix) @@ -2474,15 +2491,8 @@ impl ChatComposer { let first_line = self.textarea.text().lines().next().unwrap_or(""); if let Some((name, rest, _rest_offset)) = parse_slash_name(first_line) && rest.is_empty() - && let Some(cmd) = slash_commands::find_builtin_command( - name, - self.collaboration_modes_enabled, - self.connectors_enabled, - self.personality_command_enabled, - self.realtime_conversation_enabled, - self.audio_device_selection_enabled, - self.windows_degraded_sandbox_active, - ) + && let Some(cmd) = + slash_commands::find_builtin_command(name, self.builtin_command_flags()) { if self.reject_slash_command_if_unavailable(cmd) { return Some(InputResult::None); @@ -2510,15 +2520,7 @@ impl ChatComposer { return None; } - let cmd = slash_commands::find_builtin_command( - name, - self.collaboration_modes_enabled, - self.connectors_enabled, - self.personality_command_enabled, - self.realtime_conversation_enabled, - self.audio_device_selection_enabled, - self.windows_degraded_sandbox_active, - )?; + let cmd = slash_commands::find_builtin_command(name, self.builtin_command_flags())?; if !cmd.supports_inline_args() { return None; @@ -3330,16 +3332,8 @@ impl ChatComposer { } fn is_known_slash_name(&self, name: &str) -> bool { - let is_builtin = slash_commands::find_builtin_command( - name, - self.collaboration_modes_enabled, - self.connectors_enabled, - self.personality_command_enabled, - self.realtime_conversation_enabled, - self.audio_device_selection_enabled, - self.windows_degraded_sandbox_active, - ) - .is_some(); + let is_builtin = + slash_commands::find_builtin_command(name, self.builtin_command_flags()).is_some(); if is_builtin { return true; } @@ -3393,15 +3387,7 @@ impl ChatComposer { return rest_after_name.is_empty(); } - if slash_commands::has_builtin_prefix( - name, - self.collaboration_modes_enabled, - self.connectors_enabled, - self.personality_command_enabled, - self.realtime_conversation_enabled, - self.audio_device_selection_enabled, - self.windows_degraded_sandbox_active, - ) { + if slash_commands::has_builtin_prefix(name, self.builtin_command_flags()) { return true; } @@ -3452,6 +3438,7 @@ impl ChatComposer { if is_editing_slash_command_name { let collaboration_modes_enabled = self.collaboration_modes_enabled; let connectors_enabled = self.connectors_enabled; + let fast_command_enabled = self.fast_command_enabled; let personality_command_enabled = self.personality_command_enabled; let realtime_conversation_enabled = self.realtime_conversation_enabled; let audio_device_selection_enabled = self.audio_device_selection_enabled; @@ -3460,6 +3447,7 @@ impl ChatComposer { CommandPopupFlags { collaboration_modes_enabled, connectors_enabled, + fast_command_enabled, personality_command_enabled, realtime_conversation_enabled, audio_device_selection_enabled, @@ -4108,10 +4096,10 @@ impl ChatComposer { !footer_props.is_task_running && self.collaboration_mode_indicator.is_some(); let show_shortcuts_hint = match footer_props.mode { FooterMode::ComposerEmpty => !self.is_in_paste_burst(), + FooterMode::ComposerHasDraft => false, FooterMode::QuitShortcutReminder | FooterMode::ShortcutOverlay - | FooterMode::EscHint - | FooterMode::ComposerHasDraft => false, + | FooterMode::EscHint => false, }; let show_queue_hint = match footer_props.mode { FooterMode::ComposerHasDraft => footer_props.is_task_running, @@ -4141,10 +4129,13 @@ impl ChatComposer { .as_ref() .map(|line| line.clone().dim()); let status_line_candidate = footer_props.status_line_enabled - && matches!( - footer_props.mode, - FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft - ); + && match footer_props.mode { + FooterMode::ComposerEmpty => true, + FooterMode::ComposerHasDraft => !footer_props.is_task_running, + FooterMode::QuitShortcutReminder + | FooterMode::ShortcutOverlay + | FooterMode::EscHint => false, + }; let mut truncated_status_line = if status_line_candidate { status_line.as_ref().map(|line| { truncate_line_with_ellipsis_if_overflow(line.clone(), available_width) @@ -4210,7 +4201,7 @@ impl ChatComposer { can_show_left_with_context(hint_rect, left_width, right_width); let has_override = self.footer_flash_visible() || self.footer_hint_override.is_some(); - let single_line_layout = if has_override { + let single_line_layout = if has_override || status_line_active { None } else { match footer_props.mode { @@ -6385,6 +6376,78 @@ mod tests { assert!(composer.textarea.is_empty(), "composer should be cleared"); } + #[test] + fn kill_buffer_persists_after_submit() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + composer.textarea.insert_str("restore me"); + composer.textarea.set_cursor(0); + + let (_result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)); + assert!(composer.textarea.is_empty()); + + composer.textarea.insert_str("hello"); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(result, InputResult::Submitted { .. })); + assert!(composer.textarea.is_empty()); + + let (_result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL)); + assert_eq!(composer.textarea.text(), "restore me"); + } + + #[test] + fn kill_buffer_persists_after_slash_command_dispatch() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.textarea.insert_str("restore me"); + composer.textarea.set_cursor(0); + + let (_result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)); + assert!(composer.textarea.is_empty()); + + composer.textarea.insert_str("/diff"); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Command(cmd) => { + assert_eq!(cmd.command(), "diff"); + } + _ => panic!("expected Command result for '/diff'"), + } + assert!(composer.textarea.is_empty()); + + let (_result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL)); + assert_eq!(composer.textarea.text(), "restore me"); + } + #[test] fn slash_command_disabled_while_task_running_keeps_text() { use crossterm::event::KeyCode; diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index cf43f65686..1f774f2c74 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -38,26 +38,35 @@ pub(crate) struct CommandPopup { pub(crate) struct CommandPopupFlags { pub(crate) collaboration_modes_enabled: bool, pub(crate) connectors_enabled: bool, + pub(crate) fast_command_enabled: bool, pub(crate) personality_command_enabled: bool, pub(crate) realtime_conversation_enabled: bool, pub(crate) audio_device_selection_enabled: bool, pub(crate) windows_degraded_sandbox_active: bool, } +impl From for slash_commands::BuiltinCommandFlags { + fn from(value: CommandPopupFlags) -> Self { + Self { + collaboration_modes_enabled: value.collaboration_modes_enabled, + connectors_enabled: value.connectors_enabled, + fast_command_enabled: value.fast_command_enabled, + personality_command_enabled: value.personality_command_enabled, + realtime_conversation_enabled: value.realtime_conversation_enabled, + audio_device_selection_enabled: value.audio_device_selection_enabled, + allow_elevate_sandbox: value.windows_degraded_sandbox_active, + } + } +} + impl CommandPopup { pub(crate) fn new(mut prompts: Vec, flags: CommandPopupFlags) -> Self { // Keep built-in availability in sync with the composer. - let builtins: Vec<(&'static str, SlashCommand)> = slash_commands::builtins_for_input( - flags.collaboration_modes_enabled, - flags.connectors_enabled, - flags.personality_command_enabled, - flags.realtime_conversation_enabled, - flags.audio_device_selection_enabled, - flags.windows_degraded_sandbox_active, - ) - .into_iter() - .filter(|(name, _)| !name.starts_with("debug")) - .collect(); + let builtins: Vec<(&'static str, SlashCommand)> = + slash_commands::builtins_for_input(flags.into()) + .into_iter() + .filter(|(name, _)| !name.starts_with("debug")) + .collect(); // Exclude prompts that collide with builtin command names and sort by name. let exclude: HashSet = builtins.iter().map(|(n, _)| (*n).to_string()).collect(); prompts.retain(|p| !exclude.contains(&p.name)); @@ -498,6 +507,7 @@ mod tests { CommandPopupFlags { collaboration_modes_enabled: true, connectors_enabled: false, + fast_command_enabled: false, personality_command_enabled: true, realtime_conversation_enabled: false, audio_device_selection_enabled: false, @@ -519,6 +529,7 @@ mod tests { CommandPopupFlags { collaboration_modes_enabled: true, connectors_enabled: false, + fast_command_enabled: false, personality_command_enabled: true, realtime_conversation_enabled: false, audio_device_selection_enabled: false, @@ -540,6 +551,7 @@ mod tests { CommandPopupFlags { collaboration_modes_enabled: true, connectors_enabled: false, + fast_command_enabled: false, personality_command_enabled: false, realtime_conversation_enabled: false, audio_device_selection_enabled: false, @@ -569,6 +581,7 @@ mod tests { CommandPopupFlags { collaboration_modes_enabled: true, connectors_enabled: false, + fast_command_enabled: false, personality_command_enabled: true, realtime_conversation_enabled: false, audio_device_selection_enabled: false, @@ -590,6 +603,7 @@ mod tests { CommandPopupFlags { collaboration_modes_enabled: false, connectors_enabled: false, + fast_command_enabled: false, personality_command_enabled: true, realtime_conversation_enabled: true, audio_device_selection_enabled: false, diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index cf1d302776..2ad23272ea 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -172,10 +172,10 @@ pub(crate) fn reset_mode_after_activity(current: FooterMode) -> FooterMode { pub(crate) fn footer_height(props: &FooterProps) -> u16 { let show_shortcuts_hint = match props.mode { FooterMode::ComposerEmpty => true, - FooterMode::QuitShortcutReminder - | FooterMode::ShortcutOverlay - | FooterMode::EscHint - | FooterMode::ComposerHasDraft => false, + FooterMode::ComposerHasDraft => false, + FooterMode::QuitShortcutReminder | FooterMode::ShortcutOverlay | FooterMode::EscHint => { + false + } }; let show_queue_hint = match props.mode { FooterMode::ComposerHasDraft => props.is_task_running, @@ -562,13 +562,18 @@ fn footer_from_props_lines( show_shortcuts_hint: bool, show_queue_hint: bool, ) -> Vec> { - // If status line content is present, show it for base modes. + // If status line content is present, show it for passive composer states. + // Active draft states still prefer the queue hint over the passive status + // line so the footer stays actionable while a task is running. if props.status_line_enabled && let Some(status_line) = &props.status_line_value - && matches!( - props.mode, - FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft - ) + && match props.mode { + FooterMode::ComposerEmpty => true, + FooterMode::ComposerHasDraft => !props.is_task_running, + FooterMode::QuitShortcutReminder + | FooterMode::ShortcutOverlay + | FooterMode::EscHint => false, + } { return vec![status_line.clone().dim()]; } @@ -601,6 +606,8 @@ fn footer_from_props_lines( let state = LeftSideState { hint: if show_queue_hint { SummaryHintKind::QueueMessage + } else if show_shortcuts_hint { + SummaryHintKind::Shortcuts } else { SummaryHintKind::None }, @@ -1013,10 +1020,10 @@ mod tests { let show_cycle_hint = !props.is_task_running; let show_shortcuts_hint = match props.mode { FooterMode::ComposerEmpty => true, + FooterMode::ComposerHasDraft => false, FooterMode::QuitShortcutReminder | FooterMode::ShortcutOverlay - | FooterMode::EscHint - | FooterMode::ComposerHasDraft => false, + | FooterMode::EscHint => false, }; let show_queue_hint = match props.mode { FooterMode::ComposerHasDraft => props.is_task_running, @@ -1025,13 +1032,21 @@ mod tests { | FooterMode::ShortcutOverlay | FooterMode::EscHint => false, }; - let left_mode_indicator = if props.status_line_enabled { + let status_line_active = props.status_line_enabled + && match props.mode { + FooterMode::ComposerEmpty => true, + FooterMode::ComposerHasDraft => !props.is_task_running, + FooterMode::QuitShortcutReminder + | FooterMode::ShortcutOverlay + | FooterMode::EscHint => false, + }; + let left_mode_indicator = if status_line_active { None } else { collaboration_mode_indicator }; let available_width = area.width.saturating_sub(FOOTER_INDENT_COLS as u16) as usize; - let mut truncated_status_line = if props.status_line_enabled + let mut truncated_status_line = if status_line_active && matches!( props.mode, FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft @@ -1044,7 +1059,7 @@ mod tests { } else { None }; - let mut left_width = if props.status_line_enabled { + let mut left_width = if status_line_active { truncated_status_line .as_ref() .map(|line| line.width() as u16) @@ -1058,7 +1073,7 @@ mod tests { show_queue_hint, ) }; - let right_line = if props.status_line_enabled { + let right_line = if status_line_active { let full = mode_indicator_line(collaboration_mode_indicator, show_cycle_hint); let compact = mode_indicator_line(collaboration_mode_indicator, false); let full_width = full.as_ref().map(|line| line.width() as u16).unwrap_or(0); @@ -1077,7 +1092,7 @@ mod tests { .as_ref() .map(|line| line.width() as u16) .unwrap_or(0); - if props.status_line_enabled + if status_line_active && let Some(max_left) = max_left_width_for_right(area, right_width) && left_width > max_left && let Some(line) = props @@ -1097,21 +1112,24 @@ mod tests { props.mode, FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft ) { - let (summary_left, show_context) = single_line_footer_layout( - area, - right_width, - left_mode_indicator, - show_cycle_hint, - show_shortcuts_hint, - show_queue_hint, - ); - match summary_left { - SummaryLeft::Default => { - if props.status_line_enabled { - if let Some(line) = truncated_status_line.clone() { - render_footer_line(area, f.buffer_mut(), line); - } - } else { + if status_line_active { + if let Some(line) = truncated_status_line.clone() { + render_footer_line(area, f.buffer_mut(), line); + } + if can_show_left_and_context && let Some(line) = &right_line { + render_context_right(area, f.buffer_mut(), line); + } + } else { + let (summary_left, show_context) = single_line_footer_layout( + area, + right_width, + left_mode_indicator, + show_cycle_hint, + show_shortcuts_hint, + show_queue_hint, + ); + match summary_left { + SummaryLeft::Default => { render_footer_from_props( area, f.buffer_mut(), @@ -1122,14 +1140,14 @@ mod tests { show_queue_hint, ); } + SummaryLeft::Custom(line) => { + render_footer_line(area, f.buffer_mut(), line); + } + SummaryLeft::None => {} } - SummaryLeft::Custom(line) => { - render_footer_line(area, f.buffer_mut(), line); + if show_context && let Some(line) = &right_line { + render_context_right(area, f.buffer_mut(), line); } - SummaryLeft::None => {} - } - if show_context && let Some(line) = &right_line { - render_context_right(area, f.buffer_mut(), line); } } else { render_footer_from_props( @@ -1416,6 +1434,38 @@ mod tests { snapshot_footer("footer_status_line_overrides_shortcuts", props); + let props = FooterProps { + mode: FooterMode::ComposerHasDraft, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: true, + collaboration_modes_enabled: false, + is_wsl: false, + quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), + context_window_percent: None, + context_window_used_tokens: None, + status_line_value: Some(Line::from("Status line content".to_string())), + status_line_enabled: true, + }; + + snapshot_footer("footer_status_line_yields_to_queue_hint", props); + + let props = FooterProps { + mode: FooterMode::ComposerHasDraft, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: false, + collaboration_modes_enabled: false, + is_wsl: false, + quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), + context_window_percent: None, + context_window_used_tokens: None, + status_line_value: Some(Line::from("Status line content".to_string())), + status_line_enabled: true, + }; + + snapshot_footer("footer_status_line_overrides_draft_idle", props); + let props = FooterProps { mode: FooterMode::ComposerEmpty, esc_backtrack_hint: false, diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index ac903d996b..02c7faeebb 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -294,6 +294,11 @@ impl BottomPane { self.request_redraw(); } + pub fn set_fast_command_enabled(&mut self, enabled: bool) { + self.composer.set_fast_command_enabled(enabled); + self.request_redraw(); + } + pub fn set_realtime_conversation_enabled(&mut self, enabled: bool) { self.composer.set_realtime_conversation_enabled(enabled); self.request_redraw(); @@ -1539,7 +1544,6 @@ mod tests { dependencies: None, policy: None, permission_profile: None, - permissions: None, path_to_skills_md: PathBuf::from("test-skill"), scope: SkillScope::User, }]), diff --git a/codex-rs/tui/src/bottom_pane/slash_commands.rs b/codex-rs/tui/src/bottom_pane/slash_commands.rs index 981c61c451..85b301386b 100644 --- a/codex-rs/tui/src/bottom_pane/slash_commands.rs +++ b/codex-rs/tui/src/bottom_pane/slash_commands.rs @@ -8,72 +8,47 @@ use codex_utils_fuzzy_match::fuzzy_match; use crate::slash_command::SlashCommand; use crate::slash_command::built_in_slash_commands; +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct BuiltinCommandFlags { + pub(crate) collaboration_modes_enabled: bool, + pub(crate) connectors_enabled: bool, + pub(crate) fast_command_enabled: bool, + pub(crate) personality_command_enabled: bool, + pub(crate) realtime_conversation_enabled: bool, + pub(crate) audio_device_selection_enabled: bool, + pub(crate) allow_elevate_sandbox: bool, +} + /// Return the built-ins that should be visible/usable for the current input. -pub(crate) fn builtins_for_input( - collaboration_modes_enabled: bool, - connectors_enabled: bool, - personality_command_enabled: bool, - realtime_conversation_enabled: bool, - audio_device_selection_enabled: bool, - allow_elevate_sandbox: bool, -) -> Vec<(&'static str, SlashCommand)> { +pub(crate) fn builtins_for_input(flags: BuiltinCommandFlags) -> Vec<(&'static str, SlashCommand)> { built_in_slash_commands() .into_iter() - .filter(|(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox) + .filter(|(_, cmd)| flags.allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox) .filter(|(_, cmd)| { - collaboration_modes_enabled + flags.collaboration_modes_enabled || !matches!(*cmd, SlashCommand::Collab | SlashCommand::Plan) }) - .filter(|(_, cmd)| connectors_enabled || *cmd != SlashCommand::Apps) - .filter(|(_, cmd)| personality_command_enabled || *cmd != SlashCommand::Personality) - .filter(|(_, cmd)| realtime_conversation_enabled || *cmd != SlashCommand::Realtime) - .filter(|(_, cmd)| audio_device_selection_enabled || *cmd != SlashCommand::Settings) + .filter(|(_, cmd)| flags.connectors_enabled || *cmd != SlashCommand::Apps) + .filter(|(_, cmd)| flags.fast_command_enabled || *cmd != SlashCommand::Fast) + .filter(|(_, cmd)| flags.personality_command_enabled || *cmd != SlashCommand::Personality) + .filter(|(_, cmd)| flags.realtime_conversation_enabled || *cmd != SlashCommand::Realtime) + .filter(|(_, cmd)| flags.audio_device_selection_enabled || *cmd != SlashCommand::Settings) .collect() } /// Find a single built-in command by exact name, after applying the gating rules. -pub(crate) fn find_builtin_command( - name: &str, - collaboration_modes_enabled: bool, - connectors_enabled: bool, - personality_command_enabled: bool, - realtime_conversation_enabled: bool, - audio_device_selection_enabled: bool, - allow_elevate_sandbox: bool, -) -> Option { - builtins_for_input( - collaboration_modes_enabled, - connectors_enabled, - personality_command_enabled, - realtime_conversation_enabled, - audio_device_selection_enabled, - allow_elevate_sandbox, - ) - .into_iter() - .find(|(command_name, _)| *command_name == name) - .map(|(_, cmd)| cmd) +pub(crate) fn find_builtin_command(name: &str, flags: BuiltinCommandFlags) -> Option { + builtins_for_input(flags) + .into_iter() + .find(|(command_name, _)| *command_name == name) + .map(|(_, cmd)| cmd) } /// Whether any visible built-in fuzzily matches the provided prefix. -pub(crate) fn has_builtin_prefix( - name: &str, - collaboration_modes_enabled: bool, - connectors_enabled: bool, - personality_command_enabled: bool, - realtime_conversation_enabled: bool, - audio_device_selection_enabled: bool, - allow_elevate_sandbox: bool, -) -> bool { - builtins_for_input( - collaboration_modes_enabled, - connectors_enabled, - personality_command_enabled, - realtime_conversation_enabled, - audio_device_selection_enabled, - allow_elevate_sandbox, - ) - .into_iter() - .any(|(command_name, _)| fuzzy_match(command_name, name).is_some()) +pub(crate) fn has_builtin_prefix(name: &str, flags: BuiltinCommandFlags) -> bool { + builtins_for_input(flags) + .into_iter() + .any(|(command_name, _)| fuzzy_match(command_name, name).is_some()) } #[cfg(test)] @@ -81,41 +56,58 @@ mod tests { use super::*; use pretty_assertions::assert_eq; + fn all_enabled_flags() -> BuiltinCommandFlags { + BuiltinCommandFlags { + collaboration_modes_enabled: true, + connectors_enabled: true, + fast_command_enabled: true, + personality_command_enabled: true, + realtime_conversation_enabled: true, + audio_device_selection_enabled: true, + allow_elevate_sandbox: true, + } + } + #[test] fn debug_command_still_resolves_for_dispatch() { - let cmd = find_builtin_command("debug-config", true, true, true, false, false, false); + let cmd = find_builtin_command("debug-config", all_enabled_flags()); assert_eq!(cmd, Some(SlashCommand::DebugConfig)); } #[test] fn clear_command_resolves_for_dispatch() { assert_eq!( - find_builtin_command("clear", true, true, true, false, false, false), + find_builtin_command("clear", all_enabled_flags()), Some(SlashCommand::Clear) ); } + #[test] + fn fast_command_is_hidden_when_disabled() { + let mut flags = all_enabled_flags(); + flags.fast_command_enabled = false; + assert_eq!(find_builtin_command("fast", flags), None); + } + #[test] fn realtime_command_is_hidden_when_realtime_is_disabled() { - assert_eq!( - find_builtin_command("realtime", true, true, true, false, true, false), - None - ); + let mut flags = all_enabled_flags(); + flags.realtime_conversation_enabled = false; + assert_eq!(find_builtin_command("realtime", flags), None); } #[test] fn settings_command_is_hidden_when_realtime_is_disabled() { - assert_eq!( - find_builtin_command("settings", true, true, true, false, false, false), - None - ); + let mut flags = all_enabled_flags(); + flags.realtime_conversation_enabled = false; + flags.audio_device_selection_enabled = false; + assert_eq!(find_builtin_command("settings", flags), None); } #[test] fn settings_command_is_hidden_when_audio_device_selection_is_disabled() { - assert_eq!( - find_builtin_command("settings", true, true, true, true, false, false), - None - ); + let mut flags = all_enabled_flags(); + flags.audio_device_selection_enabled = false; + assert_eq!(find_builtin_command("settings", flags), None); } } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_overrides_draft_idle.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_overrides_draft_idle.snap new file mode 100644 index 0000000000..bb0e2d33b9 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_overrides_draft_idle.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" Status line content " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_yields_to_queue_hint.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_yields_to_queue_hint.snap new file mode 100644 index 0000000000..71370d83ba --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_yields_to_queue_hint.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" tab to queue message 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index fbb30a0322..81ff502837 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -1,3 +1,15 @@ +//! The textarea owns editable composer text, placeholder elements, cursor/wrap state, and a +//! single-entry kill buffer. +//! +//! Whole-buffer replacement APIs intentionally rebuild only the visible draft state. They clear +//! element ranges and derived cursor/wrapping caches, but they keep the kill buffer intact so a +//! caller can clear or rewrite the draft and still allow `Ctrl+Y` to restore the user's most +//! recent `Ctrl+K`. This is the contract higher-level composer flows rely on after submit, +//! slash-command dispatch, and other synthetic clears. +//! +//! This module does not implement an Emacs-style multi-entry kill ring. It keeps only the most +//! recent killed span. + use crate::key_hint::is_altgr; use codex_protocol::user_input::ByteRange; use codex_protocol::user_input::TextElement as UserTextElement; @@ -38,6 +50,14 @@ pub(crate) struct TextElementSnapshot { pub(crate) text: String, } +/// `TextArea` is the editable buffer behind the TUI composer. +/// +/// It owns the raw UTF-8 text, placeholder-like text elements that must move atomically with +/// edits, cursor/wrapping state for rendering, and a single-entry kill buffer for `Ctrl+K` / +/// `Ctrl+Y` style editing. Callers may replace the entire visible buffer through +/// [`Self::set_text_clearing_elements`] or [`Self::set_text_with_elements`] without disturbing the +/// kill buffer; if they incorrectly assume those methods fully reset editing state, a later yank +/// will appear to restore stale text from the user's perspective. #[derive(Debug)] pub(crate) struct TextArea { text: String, @@ -74,12 +94,21 @@ impl TextArea { } } - /// Replace the textarea text and clear any existing text elements. + /// Replace the visible textarea text and clear any existing text elements. + /// + /// This is the "fresh buffer" path for callers that want plain text with no placeholder + /// ranges. It intentionally preserves the current kill buffer, because higher-level flows such + /// as submit or slash-command dispatch clear the draft through this method and still want + /// `Ctrl+Y` to recover the user's most recent kill. pub fn set_text_clearing_elements(&mut self, text: &str) { self.set_text_inner(text, None); } - /// Replace the textarea text and set the provided text elements. + /// Replace the visible textarea text and rebuild the provided text elements. + /// + /// As with [`Self::set_text_clearing_elements`], this resets only state derived from the + /// visible buffer. The kill buffer survives so callers restoring drafts or external edits do + /// not silently discard a pending yank target. pub fn set_text_with_elements(&mut self, text: &str, elements: &[UserTextElement]) { self.set_text_inner(text, Some(elements)); } @@ -109,10 +138,11 @@ impl TextArea { self.elements.sort_by_key(|e| e.range.start); } // Stage 3: clamp the cursor and reset derived state tied to the prior content. + // The kill buffer is editing history rather than visible-buffer state, so full-buffer + // replacements intentionally leave it alone. self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos); self.wrap_cache.replace(None); self.preferred_col = None; - self.kill_buffer.clear(); } pub fn text(&self) -> &str { @@ -552,6 +582,12 @@ impl TextArea { } } + /// Kill from the cursor to the end of the current logical line. + /// + /// If the cursor is already at end-of-line and a trailing newline exists, this kills that + /// newline so repeated invocations continue making progress. The removed text becomes the next + /// yank target and remains available even if a caller later clears or rewrites the visible + /// buffer via `set_text_*`. pub fn kill_to_end_of_line(&mut self) { let eol = self.end_of_current_line(); let range = if self.cursor_pos == eol { @@ -582,6 +618,11 @@ impl TextArea { } } + /// Insert the most recently killed text at the cursor. + /// + /// This uses the textarea's single-entry kill buffer. Because whole-buffer replacement APIs do + /// not clear that buffer, `yank` can restore text after composer-level clears such as submit + /// and slash-command dispatch. pub fn yank(&mut self) { if self.kill_buffer.is_empty() { return; @@ -1735,6 +1776,21 @@ mod tests { assert_eq!(t.cursor(), 5); } + #[test] + fn kill_buffer_persists_across_set_text() { + let mut t = ta_with("restore me"); + t.set_cursor(0); + t.kill_to_end_of_line(); + assert!(t.text().is_empty()); + + t.set_text_clearing_elements("/diff"); + t.set_text_clearing_elements(""); + t.yank(); + + assert_eq!(t.text(), "restore me"); + assert_eq!(t.cursor(), "restore me".len()); + } + #[test] fn cursor_left_and_right_handle_graphemes() { let mut t = ta_with("a👍b"); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b887e1d97b..bbd6dfbf88 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -81,6 +81,7 @@ use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::CollaborationModeMask; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::Settings; #[cfg(target_os = "windows")] use codex_protocol::config_types::WindowsSandboxLevel; @@ -164,6 +165,10 @@ const PLAN_IMPLEMENTATION_TITLE: &str = "Implement this plan?"; const PLAN_IMPLEMENTATION_YES: &str = "Yes, implement this plan"; const PLAN_IMPLEMENTATION_NO: &str = "No, stay in Plan mode"; const PLAN_IMPLEMENTATION_CODING_MESSAGE: &str = "Implement the plan."; +const MULTI_AGENT_ENABLE_TITLE: &str = "Enable multi-agent?"; +const MULTI_AGENT_ENABLE_YES: &str = "Yes, enable"; +const MULTI_AGENT_ENABLE_NO: &str = "Not now"; +const MULTI_AGENT_ENABLE_NOTICE: &str = "Multi-agent will be enabled in the next session."; const PLAN_MODE_REASONING_SCOPE_TITLE: &str = "Apply reasoning change"; const PLAN_MODE_REASONING_SCOPE_PLAN_ONLY: &str = "Apply to Plan mode override"; const PLAN_MODE_REASONING_SCOPE_ALL_MODES: &str = "Apply to global default and Plan mode override"; @@ -1152,6 +1157,7 @@ impl ChatWidget { mask.reasoning_effort = Some(event.reasoning_effort); } self.refresh_model_display(); + self.sync_fast_command_enabled(); self.sync_personality_command_enabled(); let startup_tooltip_override = self.startup_tooltip_override.take(); let session_info_cell = history_cell::new_session_info( @@ -1568,6 +1574,41 @@ impl ChatWidget { }); } + pub(crate) fn open_multi_agent_enable_prompt(&mut self) { + let items = vec![ + SelectionItem { + name: MULTI_AGENT_ENABLE_YES.to_string(), + description: Some( + "Save the setting now. You will need a new session to use it.".to_string(), + ), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::UpdateFeatureFlags { + updates: vec![(Feature::Collab, true)], + }); + tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_warning_event(MULTI_AGENT_ENABLE_NOTICE.to_string()), + ))); + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: MULTI_AGENT_ENABLE_NO.to_string(), + description: Some("Keep multi-agent disabled.".to_string()), + dismiss_on_select: true, + ..Default::default() + }, + ]; + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some(MULTI_AGENT_ENABLE_TITLE.to_string()), + subtitle: Some("Multi-agent is currently disabled in your config.".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }); + } + pub(crate) fn set_token_info(&mut self, info: Option) { match info { Some(info) => self.apply_token_info(info), @@ -2925,6 +2966,7 @@ impl ChatWidget { .bottom_pane .set_status_line_enabled(!widget.configured_status_line_items().is_empty()); widget.bottom_pane.set_collaboration_modes_enabled(true); + widget.sync_fast_command_enabled(); widget.sync_personality_command_enabled(); widget .bottom_pane @@ -3104,6 +3146,7 @@ impl ChatWidget { .bottom_pane .set_status_line_enabled(!widget.configured_status_line_items().is_empty()); widget.bottom_pane.set_collaboration_modes_enabled(true); + widget.sync_fast_command_enabled(); widget.sync_personality_command_enabled(); widget .bottom_pane @@ -3272,6 +3315,7 @@ impl ChatWidget { .bottom_pane .set_status_line_enabled(!widget.configured_status_line_items().is_empty()); widget.bottom_pane.set_collaboration_modes_enabled(true); + widget.sync_fast_command_enabled(); widget.sync_personality_command_enabled(); widget .bottom_pane @@ -3570,6 +3614,14 @@ impl ChatWidget { SlashCommand::Model => { self.open_model_popup(); } + SlashCommand::Fast => { + let next_tier = if self.config.service_tier.is_some() { + None + } else { + Some(ServiceTier::Fast) + }; + self.set_service_tier_selection(next_tier); + } SlashCommand::Realtime => { if !self.realtime_conversation_enabled() { return; @@ -3849,6 +3901,27 @@ impl ChatWidget { let trimmed = args.trim(); match cmd { + SlashCommand::Fast => { + if trimmed.is_empty() { + self.dispatch_command(cmd); + return; + } + match trimmed.to_ascii_lowercase().as_str() { + "on" => self.set_service_tier_selection(Some(ServiceTier::Fast)), + "off" => self.set_service_tier_selection(None), + "status" => { + let status = if self.config.service_tier.is_some() { + "on" + } else { + "off" + }; + self.add_info_message(format!("Fast mode is {status}."), None); + } + _ => { + self.add_error_message("Usage: /fast [on|off|status]".to_string()); + } + } + } SlashCommand::Rename if !trimmed.is_empty() => { self.otel_manager.counter("codex.thread.rename", 1, &[]); let Some((prepared_args, _prepared_elements)) = @@ -4187,6 +4260,7 @@ impl ChatWidget { .personality .filter(|_| self.config.features.enabled(Feature::Personality)) .filter(|_| self.current_model_supports_personality()); + let service_tier = self.fast_mode_enabled().then_some(self.config.service_tier); let op = Op::UserTurn { items, cwd: self.config.cwd.clone(), @@ -4195,6 +4269,7 @@ impl ChatWidget { model: effective_mode.model().to_string(), effort: effective_mode.reasoning_effort(), summary: None, + service_tier, final_output_json_schema: None, collaboration_mode, personality, @@ -5189,6 +5264,7 @@ impl ChatWidget { model: Some(switch_model_for_events.clone()), effort: Some(Some(default_effort)), summary: None, + service_tier: None, collaboration_mode: None, personality: None, })); @@ -5308,6 +5384,7 @@ impl ChatWidget { model: None, effort: None, summary: None, + service_tier: None, collaboration_mode: None, windows_sandbox_level: None, personality: Some(personality), @@ -6221,6 +6298,7 @@ impl ChatWidget { model: None, effort: None, summary: None, + service_tier: None, collaboration_mode: None, personality: None, })); @@ -6756,6 +6834,9 @@ impl ChatWidget { self.reset_realtime_conversation_state(); } } + if feature == Feature::FastMode { + self.sync_fast_command_enabled(); + } if feature == Feature::Personality { self.sync_personality_command_enabled(); } @@ -6838,6 +6919,19 @@ impl ChatWidget { self.config.personality = Some(personality); } + /// Set Fast mode in the widget's config copy. + pub(crate) fn set_service_tier(&mut self, service_tier: Option) { + self.config.service_tier = service_tier; + } + + pub(crate) fn current_service_tier(&self) -> Option { + self.config.service_tier + } + + fn fast_mode_enabled(&self) -> bool { + self.config.features.enabled(Feature::FastMode) + } + pub(crate) fn set_realtime_audio_device( &mut self, kind: RealtimeAudioDeviceKind, @@ -6867,6 +6961,25 @@ impl ChatWidget { self.refresh_model_display(); } + fn set_service_tier_selection(&mut self, service_tier: Option) { + self.set_service_tier(service_tier); + self.app_event_tx + .send(AppEvent::CodexOp(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + windows_sandbox_level: None, + model: None, + effort: None, + summary: None, + service_tier: Some(service_tier), + collaboration_mode: None, + personality: None, + })); + self.app_event_tx + .send(AppEvent::PersistServiceTierSelection { service_tier }); + } + pub(crate) fn current_model(&self) -> &str { if !self.collaboration_modes_enabled() { return self.current_collaboration_mode.model(); @@ -6893,6 +7006,11 @@ impl ChatWidget { .unwrap_or_else(|| "System default".to_string()) } + fn sync_fast_command_enabled(&mut self) { + self.bottom_pane + .set_fast_command_enabled(self.fast_mode_enabled()); + } + fn sync_personality_command_enabled(&mut self) { self.bottom_pane .set_personality_command_enabled(self.config.features.enabled(Feature::Personality)); diff --git a/codex-rs/tui/src/chatwidget/realtime.rs b/codex-rs/tui/src/chatwidget/realtime.rs index 98ac76a34f..2970ac4d3a 100644 --- a/codex-rs/tui/src/chatwidget/realtime.rs +++ b/codex-rs/tui/src/chatwidget/realtime.rs @@ -183,12 +183,13 @@ impl ChatWidget { ev: RealtimeConversationRealtimeEvent, ) { match ev.payload { - RealtimeEvent::SessionCreated { session_id } => { + RealtimeEvent::SessionUpdated { session_id, .. } => { self.realtime_conversation.session_id = Some(session_id); } - RealtimeEvent::SessionUpdated { .. } => {} RealtimeEvent::AudioOut(frame) => self.enqueue_realtime_audio_out(&frame), RealtimeEvent::ConversationItemAdded(_item) => {} + RealtimeEvent::ConversationItemDone { .. } => {} + RealtimeEvent::HandoffRequested(_) => {} RealtimeEvent::Error(message) => { self.add_error_message(format!("Realtime voice error: {message}")); self.reset_realtime_conversation_state(); diff --git a/codex-rs/tui/src/chatwidget/skills.rs b/codex-rs/tui/src/chatwidget/skills.rs index a30e7954f3..efca42872b 100644 --- a/codex-rs/tui/src/chatwidget/skills.rs +++ b/codex-rs/tui/src/chatwidget/skills.rs @@ -191,7 +191,6 @@ fn protocol_skill_to_core(skill: &ProtocolSkillMetadata) -> SkillMetadata { }), policy: None, permission_profile: None, - permissions: None, path_to_skills_md: skill.path.clone(), scope: skill.scope, } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__multi_agent_enable_prompt.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__multi_agent_enable_prompt.snap new file mode 100644 index 0000000000..ac25d9526c --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__multi_agent_enable_prompt.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/chatwidget/tests.rs +assertion_line: 6001 +expression: popup +--- + Enable multi-agent? + Multi-agent is currently disabled in your config. + +› 1. Yes, enable Save the setting now. You will need a new session to use it. + 2. Not now Keep multi-agent disabled. + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index d89ed4bf55..a720559b5c 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -38,6 +38,7 @@ use codex_protocol::account::PlanType; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::Settings; use codex_protocol::items::AgentMessageContent; use codex_protocol::items::AgentMessageItem; @@ -160,6 +161,7 @@ async fn resumed_initial_messages_render_history() { thread_name: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), + service_tier: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/home/user/project"), @@ -229,6 +231,7 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() { thread_name: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), + service_tier: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/home/user/project"), @@ -288,6 +291,7 @@ async fn replayed_user_message_preserves_remote_image_urls() { thread_name: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), + service_tier: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/home/user/project"), @@ -354,6 +358,7 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { thread_name: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), + service_tier: None, approval_policy: AskForApproval::Never, sandbox_policy: expected_sandbox.clone(), cwd: expected_cwd.clone(), @@ -395,6 +400,7 @@ async fn replayed_user_message_with_only_remote_images_renders_history_cell() { thread_name: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), + service_tier: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/home/user/project"), @@ -446,6 +452,7 @@ async fn replayed_user_message_with_only_local_images_does_not_render_history_ce thread_name: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), + service_tier: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/home/user/project"), @@ -556,6 +563,7 @@ async fn submission_preserves_text_elements_and_local_images() { thread_name: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), + service_tier: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/home/user/project"), @@ -638,6 +646,7 @@ async fn submission_with_remote_and_local_images_keeps_local_placeholder_numberi thread_name: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), + service_tier: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/home/user/project"), @@ -731,6 +740,7 @@ async fn enter_with_only_remote_images_submits_user_turn() { thread_name: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), + service_tier: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/home/user/project"), @@ -794,6 +804,7 @@ async fn shift_enter_with_only_remote_images_does_not_submit_user_turn() { thread_name: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), + service_tier: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/home/user/project"), @@ -832,6 +843,7 @@ async fn enter_with_only_remote_images_does_not_submit_when_modal_is_active() { thread_name: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), + service_tier: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/home/user/project"), @@ -870,6 +882,7 @@ async fn enter_with_only_remote_images_does_not_submit_when_input_disabled() { thread_name: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), + service_tier: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/home/user/project"), @@ -909,6 +922,7 @@ async fn submission_prefers_selected_duplicate_skill_path() { thread_name: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), + service_tier: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/home/user/project"), @@ -936,7 +950,6 @@ async fn submission_prefers_selected_duplicate_skill_path() { dependencies: None, policy: None, permission_profile: None, - permissions: None, path_to_skills_md: repo_skill_path, scope: SkillScope::Repo, }, @@ -948,7 +961,6 @@ async fn submission_prefers_selected_duplicate_skill_path() { dependencies: None, policy: None, permission_profile: None, - permissions: None, path_to_skills_md: user_skill_path.clone(), scope: SkillScope::User, }, @@ -4426,6 +4438,7 @@ async fn plan_slash_command_with_args_submits_prompt_in_plan_mode() { thread_name: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), + service_tier: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/home/user/project"), @@ -5991,6 +6004,35 @@ async fn experimental_popup_shows_js_repl_node_requirement() { ); } +#[tokio::test] +async fn multi_agent_enable_prompt_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.open_multi_agent_enable_prompt(); + + let popup = render_bottom_popup(&chat, 80); + assert_snapshot!("multi_agent_enable_prompt", popup); +} + +#[tokio::test] +async fn multi_agent_enable_prompt_updates_feature_and_emits_notice() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.open_multi_agent_enable_prompt(); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + assert_matches!( + rx.try_recv(), + Ok(AppEvent::UpdateFeatureFlags { updates }) if updates == vec![(Feature::Collab, true)] + ); + let cell = match rx.try_recv() { + Ok(AppEvent::InsertHistoryCell(cell)) => cell, + other => panic!("expected InsertHistoryCell event, got {other:?}"), + }; + let rendered = lines_to_single_string(&cell.display_lines(120)); + assert!(rendered.contains("Multi-agent will be enabled in the next session.")); +} + #[tokio::test] async fn model_selection_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5-codex")).await; @@ -6492,6 +6534,61 @@ async fn disabled_slash_command_while_task_running_snapshot() { assert_snapshot!(blob); } +#[tokio::test] +async fn fast_slash_command_updates_and_persists_local_service_tier() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; + chat.set_feature_enabled(Feature::FastMode, true); + + chat.dispatch_command(SlashCommand::Fast); + + let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + assert!( + events.iter().any(|event| matches!( + event, + AppEvent::CodexOp(Op::OverrideTurnContext { + service_tier: Some(Some(ServiceTier::Fast)), + .. + }) + )), + "expected fast-mode override app event; events: {events:?}" + ); + assert!( + events.iter().any(|event| matches!( + event, + AppEvent::PersistServiceTierSelection { + service_tier: Some(ServiceTier::Fast), + } + )), + "expected fast-mode persistence app event; events: {events:?}" + ); + + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); +} + +#[tokio::test] +async fn user_turn_carries_service_tier_after_fast_toggle() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; + chat.thread_id = Some(ThreadId::new()); + set_chatgpt_auth(&mut chat); + chat.set_feature_enabled(Feature::FastMode, true); + + chat.dispatch_command(SlashCommand::Fast); + + let _events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + + chat.bottom_pane + .set_composer_text("hello".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { + service_tier: Some(Some(ServiceTier::Fast)), + .. + } => {} + other => panic!("expected Op::UserTurn with fast service tier, got {other:?}"), + } +} + #[tokio::test] async fn approvals_popup_shows_disabled_presets() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 8012e5acd9..e9c49e5f03 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -2490,6 +2490,7 @@ mod tests { thread_name: None, model: model.to_string(), model_provider_id: "test-provider".to_string(), + service_tier: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/tmp/project"), diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index a0556fc318..fdc2b6bdad 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -335,7 +335,7 @@ impl AuthModeWidget { let chatgpt_description = if !self.is_chatgpt_login_allowed() { "ChatGPT login is disabled" } else { - "Usage included with Plus, Pro, Team, and Enterprise plans" + "Usage included with Plus, Pro, Business, and Enterprise plans" }; let device_code_description = "Sign in from another device with a one-time code"; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 361c91a4a6..4bf3737033 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -13,6 +13,7 @@ pub enum SlashCommand { // DO NOT ALPHA-SORT! Enum order is presentation order in the popup, so // more frequently used commands should be listed first. Model, + Fast, Approvals, Permissions, #[strum(serialize = "setup-default-sandbox")] @@ -89,6 +90,7 @@ impl SlashCommand { SlashCommand::MemoryDrop => "DO NOT USE", SlashCommand::MemoryUpdate => "DO NOT USE", SlashCommand::Model => "choose what model and reasoning effort to use", + SlashCommand::Fast => "toggle Fast mode for supported models", SlashCommand::Personality => "choose a communication style for Codex", SlashCommand::Realtime => "toggle realtime voice mode (experimental)", SlashCommand::Settings => "configure realtime microphone/speaker", @@ -123,6 +125,7 @@ impl SlashCommand { SlashCommand::Review | SlashCommand::Rename | SlashCommand::Plan + | SlashCommand::Fast | SlashCommand::SandboxReadRoot ) } @@ -137,6 +140,7 @@ impl SlashCommand { | SlashCommand::Compact // | SlashCommand::Undo | SlashCommand::Model + | SlashCommand::Fast | SlashCommand::Personality | SlashCommand::Approvals | SlashCommand::Permissions diff --git a/docs/tui-chat-composer.md b/docs/tui-chat-composer.md index 40e23df3ea..5630b84eca 100644 --- a/docs/tui-chat-composer.md +++ b/docs/tui-chat-composer.md @@ -118,6 +118,12 @@ the input starts with `!` (shell command). The same preparation path is reused for slash commands with arguments (for example `/plan` and `/review`) so pasted content and text elements are preserved when extracting args. +The composer also treats the textarea kill buffer as separate editing state from the visible draft. +After submit or slash-command dispatch clears the textarea, the most recent `Ctrl+K` payload is +still available for `Ctrl+Y`. This supports flows where a user kills part of a draft, runs a +composer action such as changing reasoning level, and then yanks that text back into the cleared +draft. + ### Numeric auto-submit path When the slash popup is open and the first line matches a numeric-only custom prompt with diff --git a/scripts/install/install.ps1 b/scripts/install/install.ps1 new file mode 100644 index 0000000000..40328db700 --- /dev/null +++ b/scripts/install/install.ps1 @@ -0,0 +1,196 @@ +param( + [Parameter(Position=0)] + [string]$Version = "latest" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" + +function Write-Step { + param( + [string]$Message + ) + + Write-Host "==> $Message" +} + +function Normalize-Version { + param( + [string]$RawVersion + ) + + if ([string]::IsNullOrWhiteSpace($RawVersion) -or $RawVersion -eq "latest") { + return "latest" + } + + if ($RawVersion.StartsWith("rust-v")) { + return $RawVersion.Substring(6) + } + + if ($RawVersion.StartsWith("v")) { + return $RawVersion.Substring(1) + } + + return $RawVersion +} + +function Get-ReleaseUrl { + param( + [string]$AssetName, + [string]$ResolvedVersion + ) + + return "https://github.com/openai/codex/releases/download/rust-v$ResolvedVersion/$AssetName" +} + +function Path-Contains { + param( + [string]$PathValue, + [string]$Entry + ) + + if ([string]::IsNullOrWhiteSpace($PathValue)) { + return $false + } + + $needle = $Entry.TrimEnd("\") + foreach ($segment in $PathValue.Split(";", [System.StringSplitOptions]::RemoveEmptyEntries)) { + if ($segment.TrimEnd("\") -ieq $needle) { + return $true + } + } + + return $false +} + +function Resolve-Version { + $normalizedVersion = Normalize-Version -RawVersion $Version + if ($normalizedVersion -ne "latest") { + return $normalizedVersion + } + + $release = Invoke-RestMethod -Uri "https://api.github.com/repos/openai/codex/releases/latest" + if (-not $release.tag_name) { + Write-Error "Failed to resolve the latest Codex release version." + exit 1 + } + + return (Normalize-Version -RawVersion $release.tag_name) +} + +if ($env:OS -ne "Windows_NT") { + Write-Error "install.ps1 supports Windows only. Use install.sh on macOS or Linux." + exit 1 +} + +if (-not [Environment]::Is64BitOperatingSystem) { + Write-Error "Codex requires a 64-bit version of Windows." + exit 1 +} + +$architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture +$target = $null +$platformLabel = $null +$npmTag = $null +switch ($architecture) { + "Arm64" { + $target = "aarch64-pc-windows-msvc" + $platformLabel = "Windows (ARM64)" + $npmTag = "win32-arm64" + } + "X64" { + $target = "x86_64-pc-windows-msvc" + $platformLabel = "Windows (x64)" + $npmTag = "win32-x64" + } + default { + Write-Error "Unsupported architecture: $architecture" + exit 1 + } +} + +if ([string]::IsNullOrWhiteSpace($env:CODEX_INSTALL_DIR)) { + $installDir = Join-Path $env:LOCALAPPDATA "Programs\OpenAI\Codex\bin" +} else { + $installDir = $env:CODEX_INSTALL_DIR +} + +$codexPath = Join-Path $installDir "codex.exe" +$installMode = if (Test-Path $codexPath) { "Updating" } else { "Installing" } + +Write-Step "$installMode Codex CLI" +Write-Step "Detected platform: $platformLabel" + +New-Item -ItemType Directory -Force -Path $installDir | Out-Null + +$resolvedVersion = Resolve-Version +Write-Step "Resolved version: $resolvedVersion" +$packageAsset = "codex-npm-$npmTag-$resolvedVersion.tgz" + +$tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("codex-install-" + [System.Guid]::NewGuid().ToString("N")) +New-Item -ItemType Directory -Force -Path $tempDir | Out-Null + +try { + $archivePath = Join-Path $tempDir $packageAsset + $extractDir = Join-Path $tempDir "extract" + $url = Get-ReleaseUrl -AssetName $packageAsset -ResolvedVersion $resolvedVersion + + Write-Step "Downloading Codex CLI" + Invoke-WebRequest -Uri $url -OutFile $archivePath + + New-Item -ItemType Directory -Force -Path $extractDir | Out-Null + tar -xzf $archivePath -C $extractDir + + $vendorRoot = Join-Path $extractDir "package/vendor/$target" + Write-Step "Installing to $installDir" + $copyMap = @{ + "codex/codex.exe" = "codex.exe" + "codex/codex-command-runner.exe" = "codex-command-runner.exe" + "codex/codex-windows-sandbox-setup.exe" = "codex-windows-sandbox-setup.exe" + "path/rg.exe" = "rg.exe" + } + + foreach ($relativeSource in $copyMap.Keys) { + $sourcePath = Join-Path $vendorRoot $relativeSource + $destinationPath = Join-Path $installDir $copyMap[$relativeSource] + Move-Item -Force $sourcePath $destinationPath + } +} finally { + Remove-Item -Recurse -Force $tempDir -ErrorAction SilentlyContinue +} + +$userPath = [Environment]::GetEnvironmentVariable("Path", "User") +$pathNeedsNewShell = $false +if (-not (Path-Contains -PathValue $userPath -Entry $installDir)) { + if ([string]::IsNullOrWhiteSpace($userPath)) { + $newUserPath = $installDir + } else { + $newUserPath = "$installDir;$userPath" + } + + [Environment]::SetEnvironmentVariable("Path", $newUserPath, "User") + if (-not (Path-Contains -PathValue $env:Path -Entry $installDir)) { + if ([string]::IsNullOrWhiteSpace($env:Path)) { + $env:Path = $installDir + } else { + $env:Path = "$installDir;$env:Path" + } + } + Write-Step "PATH updated for future PowerShell sessions." + $pathNeedsNewShell = $true +} elseif (Path-Contains -PathValue $env:Path -Entry $installDir) { + Write-Step "$installDir is already on PATH." +} else { + Write-Step "PATH is already configured for future PowerShell sessions." + $pathNeedsNewShell = $true +} + +if ($pathNeedsNewShell) { + Write-Step ('Run now: $env:Path = "{0};$env:Path"; codex' -f $installDir) + Write-Step "Or open a new PowerShell window and run: codex" +} else { + Write-Step "Run: codex" +} + +Write-Host "Codex CLI $resolvedVersion installed successfully."