mirror of
https://github.com/openai/codex.git
synced 2026-02-07 17:33:41 +00:00
Compare commits
3 Commits
ae/slow
...
codex/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f3f2add3e | ||
|
|
7ec9444fb6 | ||
|
|
8ec36fc336 |
25
AGENTS.md
25
AGENTS.md
@@ -73,28 +73,3 @@ If you don’t have the tool:
|
||||
### Test assertions
|
||||
|
||||
- Tests should use pretty_assertions::assert_eq for clearer diffs. Import this at the top of the test module if it isn't already.
|
||||
|
||||
### Integration tests (core)
|
||||
|
||||
- Prefer the utilities in `core_test_support::responses` when writing end-to-end Codex tests.
|
||||
|
||||
- All `mount_sse*` helpers return a `ResponseMock`; hold onto it so you can assert against outbound `/responses` POST bodies.
|
||||
- Use `ResponseMock::single_request()` when a test should only issue one POST, or `ResponseMock::requests()` to inspect every captured `ResponsesRequest`.
|
||||
- `ResponsesRequest` exposes helpers (`body_json`, `input`, `function_call_output`, `custom_tool_call_output`, `call_output`, `header`, `path`, `query_param`) so assertions can target structured payloads instead of manual JSON digging.
|
||||
- Build SSE payloads with the provided `ev_*` constructors and the `sse(...)`.
|
||||
|
||||
- Typical pattern:
|
||||
|
||||
```rust
|
||||
let mock = responses::mount_sse_once(&server, responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_function_call(call_id, "shell", &serde_json::to_string(&args)?),
|
||||
responses::ev_completed("resp-1"),
|
||||
])).await;
|
||||
|
||||
codex.submit(Op::UserTurn { ... }).await?;
|
||||
|
||||
// Assert request body if needed.
|
||||
let request = mock.single_request();
|
||||
// assert using request.function_call_output(call_id) or request.json_body() or other helpers.
|
||||
```
|
||||
|
||||
110
codex-rs/Cargo.lock
generated
110
codex-rs/Cargo.lock
generated
@@ -992,7 +992,7 @@ dependencies = [
|
||||
"tokio-stream",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"unicode-width 0.2.1",
|
||||
"unicode-width 0.1.14",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1088,6 +1088,7 @@ dependencies = [
|
||||
"walkdir",
|
||||
"which",
|
||||
"wildmatch",
|
||||
"windows 0.58.0",
|
||||
"wiremock",
|
||||
]
|
||||
|
||||
@@ -1424,8 +1425,6 @@ dependencies = [
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
"tree-sitter-bash",
|
||||
"tree-sitter-highlight",
|
||||
"unicode-segmentation",
|
||||
"unicode-width 0.2.1",
|
||||
"url",
|
||||
@@ -2820,7 +2819,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core",
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4387,7 +4386,7 @@ dependencies = [
|
||||
"nix 0.30.1",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6021,7 +6020,6 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"futures-util",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
@@ -6263,9 +6261,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter"
|
||||
version = "0.25.10"
|
||||
version = "0.25.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78f873475d258561b06f1c595d93308a7ed124d9977cb26b148c2084a4a3cc87"
|
||||
checksum = "ccd2a058a86cfece0bf96f7cce1021efef9c8ed0e892ab74639173e5ed7a34fa"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"regex",
|
||||
@@ -6285,18 +6283,6 @@ dependencies = [
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-highlight"
|
||||
version = "0.25.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adc5f880ad8d8f94e88cb81c3557024cf1a8b75e3b504c50481ed4f5a6006ff3"
|
||||
dependencies = [
|
||||
"regex",
|
||||
"streaming-iterator",
|
||||
"thiserror 2.0.16",
|
||||
"tree-sitter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-language"
|
||||
version = "0.1.5"
|
||||
@@ -6717,6 +6703,16 @@ version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.58.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
|
||||
dependencies = [
|
||||
"windows-core 0.58.0",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.61.3"
|
||||
@@ -6724,7 +6720,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
|
||||
dependencies = [
|
||||
"windows-collections",
|
||||
"windows-core",
|
||||
"windows-core 0.61.2",
|
||||
"windows-future",
|
||||
"windows-link 0.1.3",
|
||||
"windows-numerics",
|
||||
@@ -6736,7 +6732,20 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.58.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
|
||||
dependencies = [
|
||||
"windows-implement 0.58.0",
|
||||
"windows-interface 0.58.0",
|
||||
"windows-result 0.2.0",
|
||||
"windows-strings 0.1.0",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6745,11 +6754,11 @@ version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-implement 0.60.0",
|
||||
"windows-interface 0.59.1",
|
||||
"windows-link 0.1.3",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
"windows-result 0.3.4",
|
||||
"windows-strings 0.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6758,11 +6767,22 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-core 0.61.2",
|
||||
"windows-link 0.1.3",
|
||||
"windows-threading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.58.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.104",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.0"
|
||||
@@ -6774,6 +6794,17 @@ dependencies = [
|
||||
"syn 2.0.104",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.58.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.104",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.1"
|
||||
@@ -6803,7 +6834,7 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-core 0.61.2",
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
@@ -6814,8 +6845,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
|
||||
dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
"windows-result 0.3.4",
|
||||
"windows-strings 0.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6827,6 +6867,16 @@ dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
|
||||
dependencies = [
|
||||
"windows-result 0.2.0",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.4.2"
|
||||
|
||||
@@ -175,9 +175,8 @@ tracing = "0.1.41"
|
||||
tracing-appender = "0.2.3"
|
||||
tracing-subscriber = "0.3.20"
|
||||
tracing-test = "0.2.5"
|
||||
tree-sitter = "0.25.10"
|
||||
tree-sitter-bash = "0.25"
|
||||
tree-sitter-highlight = "0.25.10"
|
||||
tree-sitter = "0.25.9"
|
||||
tree-sitter-bash = "0.25.0"
|
||||
ts-rs = "11"
|
||||
unicode-segmentation = "1.12.0"
|
||||
unicode-width = "0.2"
|
||||
|
||||
@@ -157,7 +157,9 @@ struct LoginCommand {
|
||||
)]
|
||||
api_key: Option<String>,
|
||||
|
||||
#[arg(long = "use-device-code")]
|
||||
/// EXPERIMENTAL: Use device code flow (not yet supported)
|
||||
/// This feature is experimental and may changed in future releases.
|
||||
#[arg(long = "experimental_use-device-code", hide = true)]
|
||||
use_device_code: bool,
|
||||
|
||||
/// EXPERIMENTAL: Use custom OAuth issuer base URL (advanced)
|
||||
@@ -292,8 +294,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
|
||||
last,
|
||||
config_overrides,
|
||||
);
|
||||
let exit_info = codex_tui::run_main(interactive, codex_linux_sandbox_exe).await?;
|
||||
print_exit_messages(exit_info);
|
||||
codex_tui::run_main(interactive, codex_linux_sandbox_exe).await?;
|
||||
}
|
||||
Some(Subcommand::Login(mut login_cli)) => {
|
||||
prepend_config_flags(
|
||||
|
||||
@@ -11,10 +11,10 @@ path = "src/lib.rs"
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
clap = { workspace = true, features = ["derive"] }
|
||||
anyhow = "1"
|
||||
base64 = "0.22"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
codex-cloud-tasks-client = { path = "../cloud-tasks-client", features = [
|
||||
"mock",
|
||||
"online",
|
||||
@@ -23,16 +23,16 @@ codex-common = { path = "../common", features = ["cli"] }
|
||||
codex-core = { path = "../core" }
|
||||
codex-login = { path = "../login" }
|
||||
codex-tui = { path = "../tui" }
|
||||
crossterm = { workspace = true, features = ["event-stream"] }
|
||||
ratatui = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["json"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||
tokio-stream = { workspace = true }
|
||||
tracing = { workspace = true, features = ["log"] }
|
||||
tracing-subscriber = { workspace = true, features = ["env-filter"] }
|
||||
unicode-width = { workspace = true }
|
||||
crossterm = { version = "0.28.1", features = ["event-stream"] }
|
||||
ratatui = { version = "0.29.0" }
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||
tokio-stream = "0.1.17"
|
||||
tracing = { version = "0.1.41", features = ["log"] }
|
||||
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
||||
unicode-width = "0.1"
|
||||
|
||||
[dev-dependencies]
|
||||
async-trait = { workspace = true }
|
||||
async-trait = "0.1"
|
||||
|
||||
@@ -61,7 +61,7 @@ tokio = { workspace = true, features = [
|
||||
"rt-multi-thread",
|
||||
"signal",
|
||||
] }
|
||||
tokio-util = { workspace = true, features = ["rt"] }
|
||||
tokio-util = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
toml_edit = { workspace = true }
|
||||
tracing = { workspace = true, features = ["log"] }
|
||||
@@ -79,6 +79,21 @@ seccompiler = { workspace = true }
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
core-foundation = "0.9"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.58", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Security_Isolation",
|
||||
"Win32_Security",
|
||||
"Win32_Security_Authorization",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_System_Memory",
|
||||
"Win32_System_Threading",
|
||||
] }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
windows_appcontainer_command_ext = []
|
||||
|
||||
# Build OpenSSL from source for musl builds.
|
||||
[target.x86_64-unknown-linux-musl.dependencies]
|
||||
openssl-sys = { workspace = true, features = ["vendored"] }
|
||||
|
||||
@@ -23,9 +23,7 @@ use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::TaskStartedEvent;
|
||||
use codex_protocol::protocol::TurnAbortReason;
|
||||
use codex_protocol::protocol::TurnContextItem;
|
||||
use futures::future::BoxFuture;
|
||||
use futures::prelude::*;
|
||||
use futures::stream::FuturesOrdered;
|
||||
use mcp_types::CallToolResult;
|
||||
use serde_json;
|
||||
use serde_json::Value;
|
||||
@@ -1586,7 +1584,7 @@ async fn spawn_review_thread(
|
||||
|
||||
// Seed the child task with the review prompt as the initial user message.
|
||||
let input: Vec<InputItem> = vec![InputItem::Text {
|
||||
text: review_prompt,
|
||||
text: format!("{base_instructions}\n\n---\n\nNow, here's your task: {review_prompt}"),
|
||||
}];
|
||||
let tc = Arc::new(review_turn_context);
|
||||
|
||||
@@ -2103,15 +2101,14 @@ async fn try_run_turn(
|
||||
sess.persist_rollout_items(&[rollout_item]).await;
|
||||
let mut stream = turn_context.client.clone().stream(&prompt).await?;
|
||||
|
||||
let tool_runtime = ToolCallRuntime::new(
|
||||
let mut output = Vec::new();
|
||||
let mut tool_runtime = ToolCallRuntime::new(
|
||||
Arc::clone(&router),
|
||||
Arc::clone(&sess),
|
||||
Arc::clone(&turn_context),
|
||||
Arc::clone(&turn_diff_tracker),
|
||||
sub_id.to_string(),
|
||||
);
|
||||
let mut output: FuturesOrdered<BoxFuture<CodexResult<ProcessedResponseItem>>> =
|
||||
FuturesOrdered::new();
|
||||
|
||||
loop {
|
||||
// Poll the next item from the model stream. We must inspect *both* Ok and Err
|
||||
@@ -2119,8 +2116,9 @@ async fn try_run_turn(
|
||||
// `response.completed`) bubble up and trigger the caller's retry logic.
|
||||
let event = stream.next().await;
|
||||
let event = match event {
|
||||
Some(res) => res?,
|
||||
Some(event) => event,
|
||||
None => {
|
||||
tool_runtime.abort_all();
|
||||
return Err(CodexErr::Stream(
|
||||
"stream closed before response.completed".into(),
|
||||
None,
|
||||
@@ -2128,8 +2126,14 @@ async fn try_run_turn(
|
||||
}
|
||||
};
|
||||
|
||||
let add_completed = &mut |response_item: ProcessedResponseItem| {
|
||||
output.push_back(future::ready(Ok(response_item)).boxed());
|
||||
let event = match event {
|
||||
Ok(ev) => ev,
|
||||
Err(e) => {
|
||||
tool_runtime.abort_all();
|
||||
// Propagate the underlying stream error to the caller (run_turn), which
|
||||
// will apply the configured `stream_max_retries` policy.
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
match event {
|
||||
@@ -2139,18 +2143,14 @@ async fn try_run_turn(
|
||||
Ok(Some(call)) => {
|
||||
let payload_preview = call.payload.log_payload().into_owned();
|
||||
tracing::info!("ToolCall: {} {}", call.tool_name, payload_preview);
|
||||
|
||||
let response = tool_runtime.handle_tool_call(call);
|
||||
|
||||
output.push_back(
|
||||
async move {
|
||||
Ok(ProcessedResponseItem {
|
||||
item,
|
||||
response: Some(response.await?),
|
||||
})
|
||||
}
|
||||
.boxed(),
|
||||
);
|
||||
let index = output.len();
|
||||
output.push(ProcessedResponseItem {
|
||||
item,
|
||||
response: None,
|
||||
});
|
||||
tool_runtime
|
||||
.handle_tool_call(call, index, output.as_mut_slice())
|
||||
.await?;
|
||||
}
|
||||
Ok(None) => {
|
||||
let response = handle_non_tool_response_item(
|
||||
@@ -2160,7 +2160,7 @@ async fn try_run_turn(
|
||||
item.clone(),
|
||||
)
|
||||
.await?;
|
||||
add_completed(ProcessedResponseItem { item, response });
|
||||
output.push(ProcessedResponseItem { item, response });
|
||||
}
|
||||
Err(FunctionCallError::MissingLocalShellCallId) => {
|
||||
let msg = "LocalShellCall without call_id or id";
|
||||
@@ -2177,7 +2177,7 @@ async fn try_run_turn(
|
||||
success: None,
|
||||
},
|
||||
};
|
||||
add_completed(ProcessedResponseItem {
|
||||
output.push(ProcessedResponseItem {
|
||||
item,
|
||||
response: Some(response),
|
||||
});
|
||||
@@ -2190,7 +2190,7 @@ async fn try_run_turn(
|
||||
success: None,
|
||||
},
|
||||
};
|
||||
add_completed(ProcessedResponseItem {
|
||||
output.push(ProcessedResponseItem {
|
||||
item,
|
||||
response: Some(response),
|
||||
});
|
||||
@@ -2221,7 +2221,7 @@ async fn try_run_turn(
|
||||
sess.update_token_usage_info(sub_id, turn_context.as_ref(), token_usage.as_ref())
|
||||
.await;
|
||||
|
||||
let processed_items: Vec<ProcessedResponseItem> = output.try_collect().await?;
|
||||
tool_runtime.resolve_pending(output.as_mut_slice()).await?;
|
||||
|
||||
let unified_diff = {
|
||||
let mut tracker = turn_diff_tracker.lock().await;
|
||||
@@ -2237,7 +2237,7 @@ async fn try_run_turn(
|
||||
}
|
||||
|
||||
let result = TurnRunResult {
|
||||
processed_items,
|
||||
processed_items: output,
|
||||
total_token_usage: token_usage.clone(),
|
||||
};
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ use crate::model_provider_info::built_in_model_providers;
|
||||
use crate::openai_model_info::get_model_info;
|
||||
use crate::protocol::AskForApproval;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::safety::set_windows_sandbox_enabled;
|
||||
use anyhow::Context;
|
||||
use codex_app_server_protocol::Tools;
|
||||
use codex_app_server_protocol::UserSavedConfig;
|
||||
@@ -170,6 +171,9 @@ pub struct Config {
|
||||
/// When this program is invoked, arg0 will be set to `codex-linux-sandbox`.
|
||||
pub codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
|
||||
/// Enable the experimental Windows sandbox implementation.
|
||||
pub experimental_windows_sandbox: bool,
|
||||
|
||||
/// Value to use for `reasoning.effort` when making a request using the
|
||||
/// Responses API.
|
||||
pub model_reasoning_effort: Option<ReasoningEffort>,
|
||||
@@ -751,6 +755,7 @@ pub struct ConfigToml {
|
||||
pub experimental_use_unified_exec_tool: Option<bool>,
|
||||
pub experimental_use_rmcp_client: Option<bool>,
|
||||
pub experimental_use_freeform_apply_patch: Option<bool>,
|
||||
pub experimental_windows_sandbox: Option<bool>,
|
||||
|
||||
pub projects: Option<HashMap<String, ProjectConfig>>,
|
||||
|
||||
@@ -1008,6 +1013,8 @@ impl Config {
|
||||
.or(cfg.tools.as_ref().and_then(|t| t.view_image))
|
||||
.unwrap_or(true);
|
||||
|
||||
let experimental_windows_sandbox = cfg.experimental_windows_sandbox.unwrap_or(false);
|
||||
|
||||
let model = model
|
||||
.or(config_profile.model)
|
||||
.or(cfg.model)
|
||||
@@ -1093,6 +1100,7 @@ impl Config {
|
||||
history,
|
||||
file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode),
|
||||
codex_linux_sandbox_exe,
|
||||
experimental_windows_sandbox,
|
||||
|
||||
hide_agent_reasoning: cfg.hide_agent_reasoning.unwrap_or(false),
|
||||
show_raw_agent_reasoning: cfg
|
||||
@@ -1146,6 +1154,7 @@ impl Config {
|
||||
}
|
||||
},
|
||||
};
|
||||
set_windows_sandbox_enabled(config.experimental_windows_sandbox);
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
@@ -1903,6 +1912,7 @@ model_verbosity = "high"
|
||||
history: History::default(),
|
||||
file_opener: UriBasedFileOpener::VsCode,
|
||||
codex_linux_sandbox_exe: None,
|
||||
experimental_windows_sandbox: false,
|
||||
hide_agent_reasoning: false,
|
||||
show_raw_agent_reasoning: false,
|
||||
model_reasoning_effort: Some(ReasoningEffort::High),
|
||||
@@ -1965,6 +1975,7 @@ model_verbosity = "high"
|
||||
history: History::default(),
|
||||
file_opener: UriBasedFileOpener::VsCode,
|
||||
codex_linux_sandbox_exe: None,
|
||||
experimental_windows_sandbox: false,
|
||||
hide_agent_reasoning: false,
|
||||
show_raw_agent_reasoning: false,
|
||||
model_reasoning_effort: None,
|
||||
@@ -2042,6 +2053,7 @@ model_verbosity = "high"
|
||||
history: History::default(),
|
||||
file_opener: UriBasedFileOpener::VsCode,
|
||||
codex_linux_sandbox_exe: None,
|
||||
experimental_windows_sandbox: false,
|
||||
hide_agent_reasoning: false,
|
||||
show_raw_agent_reasoning: false,
|
||||
model_reasoning_effort: None,
|
||||
@@ -2105,6 +2117,7 @@ model_verbosity = "high"
|
||||
history: History::default(),
|
||||
file_opener: UriBasedFileOpener::VsCode,
|
||||
codex_linux_sandbox_exe: None,
|
||||
experimental_windows_sandbox: false,
|
||||
hide_agent_reasoning: false,
|
||||
show_raw_agent_reasoning: false,
|
||||
model_reasoning_effort: Some(ReasoningEffort::High),
|
||||
|
||||
@@ -20,7 +20,7 @@ use std::sync::OnceLock;
|
||||
/// The full user agent string is returned from the mcp initialize response.
|
||||
/// Parenthesis will be added by Codex. This should only specify what goes inside of the parenthesis.
|
||||
pub static USER_AGENT_SUFFIX: LazyLock<Mutex<Option<String>>> = LazyLock::new(|| Mutex::new(None));
|
||||
pub const DEFAULT_ORIGINATOR: &str = "codex_cli_rs";
|
||||
|
||||
pub const CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR: &str = "CODEX_INTERNAL_ORIGINATOR_OVERRIDE";
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Originator {
|
||||
@@ -35,11 +35,10 @@ pub enum SetOriginatorError {
|
||||
AlreadyInitialized,
|
||||
}
|
||||
|
||||
fn get_originator_value(provided: Option<String>) -> Originator {
|
||||
fn init_originator_from_env() -> Originator {
|
||||
let default = "codex_cli_rs";
|
||||
let value = std::env::var(CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR)
|
||||
.ok()
|
||||
.or(provided)
|
||||
.unwrap_or(DEFAULT_ORIGINATOR.to_string());
|
||||
.unwrap_or_else(|_| default.to_string());
|
||||
|
||||
match HeaderValue::from_str(&value) {
|
||||
Ok(header_value) => Originator {
|
||||
@@ -49,22 +48,31 @@ fn get_originator_value(provided: Option<String>) -> Originator {
|
||||
Err(e) => {
|
||||
tracing::error!("Unable to turn originator override {value} into header value: {e}");
|
||||
Originator {
|
||||
value: DEFAULT_ORIGINATOR.to_string(),
|
||||
header_value: HeaderValue::from_static(DEFAULT_ORIGINATOR),
|
||||
value: default.to_string(),
|
||||
header_value: HeaderValue::from_static(default),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_default_originator(value: String) -> Result<(), SetOriginatorError> {
|
||||
let originator = get_originator_value(Some(value));
|
||||
fn build_originator(value: String) -> Result<Originator, SetOriginatorError> {
|
||||
let header_value =
|
||||
HeaderValue::from_str(&value).map_err(|_| SetOriginatorError::InvalidHeaderValue)?;
|
||||
Ok(Originator {
|
||||
value,
|
||||
header_value,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_default_originator(value: &str) -> Result<(), SetOriginatorError> {
|
||||
let originator = build_originator(value.to_string())?;
|
||||
ORIGINATOR
|
||||
.set(originator)
|
||||
.map_err(|_| SetOriginatorError::AlreadyInitialized)
|
||||
}
|
||||
|
||||
pub fn originator() -> &'static Originator {
|
||||
ORIGINATOR.get_or_init(|| get_originator_value(None))
|
||||
ORIGINATOR.get_or_init(init_originator_from_env)
|
||||
}
|
||||
|
||||
pub fn get_codex_user_agent() -> String {
|
||||
|
||||
@@ -27,6 +27,8 @@ use crate::protocol::SandboxPolicy;
|
||||
use crate::seatbelt::spawn_command_under_seatbelt;
|
||||
use crate::spawn::StdioPolicy;
|
||||
use crate::spawn::spawn_child_async;
|
||||
#[cfg(windows)]
|
||||
use crate::windows_appcontainer::spawn_command_under_windows_appcontainer;
|
||||
|
||||
const DEFAULT_TIMEOUT_MS: u64 = 10_000;
|
||||
|
||||
@@ -70,6 +72,9 @@ pub enum SandboxType {
|
||||
|
||||
/// Only available on Linux.
|
||||
LinuxSeccomp,
|
||||
|
||||
/// Only available on Windows.
|
||||
WindowsAppContainer,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -94,6 +99,31 @@ pub async fn process_exec_tool_call(
|
||||
let raw_output_result: std::result::Result<RawExecToolCallOutput, CodexErr> = match sandbox_type
|
||||
{
|
||||
SandboxType::None => exec(params, sandbox_policy, stdout_stream.clone()).await,
|
||||
SandboxType::WindowsAppContainer => {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let ExecParams {
|
||||
command,
|
||||
cwd: command_cwd,
|
||||
env,
|
||||
..
|
||||
} = params;
|
||||
let child = spawn_command_under_windows_appcontainer(
|
||||
command,
|
||||
command_cwd,
|
||||
sandbox_policy,
|
||||
sandbox_cwd,
|
||||
StdioPolicy::RedirectForShellTool,
|
||||
env,
|
||||
)
|
||||
.await?;
|
||||
consume_truncated_output(child, timeout_duration, stdout_stream.clone()).await
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
panic!("windows sandboxing is not available on this platform");
|
||||
}
|
||||
}
|
||||
SandboxType::MacosSeatbelt => {
|
||||
let ExecParams {
|
||||
command,
|
||||
@@ -198,7 +228,10 @@ pub async fn process_exec_tool_call(
|
||||
/// For now, we conservatively check for 'command not found' (exit code 127),
|
||||
/// and can add additional cases as necessary.
|
||||
fn is_likely_sandbox_denied(sandbox_type: SandboxType, exit_code: i32) -> bool {
|
||||
if sandbox_type == SandboxType::None {
|
||||
if matches!(
|
||||
sandbox_type,
|
||||
SandboxType::None | SandboxType::WindowsAppContainer
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,9 @@ mod tasks;
|
||||
mod user_notification;
|
||||
pub mod util;
|
||||
|
||||
#[cfg(windows)]
|
||||
pub mod windows_appcontainer;
|
||||
|
||||
pub use apply_patch::CODEX_APPLY_PATCH_ARG1;
|
||||
pub use command_safety::is_safe_command;
|
||||
pub use safety::get_platform_sandbox;
|
||||
|
||||
@@ -120,7 +120,6 @@ pub fn find_family_for_model(mut slug: &str) -> Option<ModelFamily> {
|
||||
base_instructions: GPT_5_CODEX_INSTRUCTIONS.to_string(),
|
||||
experimental_supported_tools: vec![
|
||||
"read_file".to_string(),
|
||||
"list_dir".to_string(),
|
||||
"test_sync_tool".to_string()
|
||||
],
|
||||
supports_parallel_tool_calls: true,
|
||||
@@ -134,7 +133,7 @@ pub fn find_family_for_model(mut slug: &str) -> Option<ModelFamily> {
|
||||
reasoning_summary_format: ReasoningSummaryFormat::Experimental,
|
||||
base_instructions: GPT_5_CODEX_INSTRUCTIONS.to_string(),
|
||||
apply_patch_tool_type: Some(ApplyPatchToolType::Freeform),
|
||||
experimental_supported_tools: vec!["read_file".to_string(), "list_dir".to_string()],
|
||||
experimental_supported_tools: vec!["read_file".to_string()],
|
||||
supports_parallel_tool_calls: true,
|
||||
)
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ use std::collections::HashSet;
|
||||
use std::path::Component;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use codex_apply_patch::ApplyPatchAction;
|
||||
use codex_apply_patch::ApplyPatchFileChange;
|
||||
@@ -13,6 +15,12 @@ use crate::command_safety::is_safe_command::is_known_safe_command;
|
||||
use crate::protocol::AskForApproval;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
|
||||
static WINDOWS_SANDBOX_ENABLED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
pub(crate) fn set_windows_sandbox_enabled(enabled: bool) {
|
||||
WINDOWS_SANDBOX_ENABLED.store(enabled, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum SafetyCheck {
|
||||
AutoApprove {
|
||||
@@ -206,6 +214,12 @@ pub fn get_platform_sandbox() -> Option<SandboxType> {
|
||||
Some(SandboxType::MacosSeatbelt)
|
||||
} else if cfg!(target_os = "linux") {
|
||||
Some(SandboxType::LinuxSeccomp)
|
||||
} else if cfg!(target_os = "windows") {
|
||||
if WINDOWS_SANDBOX_ENABLED.load(Ordering::Relaxed) {
|
||||
Some(SandboxType::WindowsAppContainer)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -436,4 +450,19 @@ mod tests {
|
||||
};
|
||||
assert_eq!(safety_check, expected);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn windows_sandbox_toggle_controls_platform_sandbox() {
|
||||
set_windows_sandbox_enabled(false);
|
||||
assert_eq!(get_platform_sandbox(), None);
|
||||
|
||||
set_windows_sandbox_enabled(true);
|
||||
assert_eq!(
|
||||
get_platform_sandbox(),
|
||||
Some(SandboxType::WindowsAppContainer)
|
||||
);
|
||||
|
||||
set_windows_sandbox_enabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,476 +0,0 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::ffi::OsStr;
|
||||
use std::fs::FileType;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use codex_utils_string::take_bytes_at_char_boundary;
|
||||
use serde::Deserialize;
|
||||
use tokio::fs;
|
||||
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolOutput;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::registry::ToolHandler;
|
||||
use crate::tools::registry::ToolKind;
|
||||
|
||||
pub struct ListDirHandler;
|
||||
|
||||
const MAX_ENTRY_LENGTH: usize = 500;
|
||||
const INDENTATION_SPACES: usize = 2;
|
||||
|
||||
fn default_offset() -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn default_limit() -> usize {
|
||||
25
|
||||
}
|
||||
|
||||
fn default_depth() -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ListDirArgs {
|
||||
dir_path: String,
|
||||
#[serde(default = "default_offset")]
|
||||
offset: usize,
|
||||
#[serde(default = "default_limit")]
|
||||
limit: usize,
|
||||
#[serde(default = "default_depth")]
|
||||
depth: usize,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ToolHandler for ListDirHandler {
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<ToolOutput, FunctionCallError> {
|
||||
let ToolInvocation { payload, .. } = invocation;
|
||||
|
||||
let arguments = match payload {
|
||||
ToolPayload::Function { arguments } => arguments,
|
||||
_ => {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"list_dir handler received unsupported payload".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let args: ListDirArgs = serde_json::from_str(&arguments).map_err(|err| {
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"failed to parse function arguments: {err:?}"
|
||||
))
|
||||
})?;
|
||||
|
||||
let ListDirArgs {
|
||||
dir_path,
|
||||
offset,
|
||||
limit,
|
||||
depth,
|
||||
} = args;
|
||||
|
||||
if offset == 0 {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"offset must be a 1-indexed entry number".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if limit == 0 {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"limit must be greater than zero".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if depth == 0 {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"depth must be greater than zero".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let path = PathBuf::from(&dir_path);
|
||||
if !path.is_absolute() {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"dir_path must be an absolute path".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let entries = list_dir_slice(&path, offset, limit, depth).await?;
|
||||
let mut output = Vec::with_capacity(entries.len() + 1);
|
||||
output.push(format!("Absolute path: {}", path.display()));
|
||||
output.extend(entries);
|
||||
Ok(ToolOutput::Function {
|
||||
content: output.join("\n"),
|
||||
success: Some(true),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_dir_slice(
|
||||
path: &Path,
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
depth: usize,
|
||||
) -> Result<Vec<String>, FunctionCallError> {
|
||||
let mut entries = Vec::new();
|
||||
collect_entries(path, Path::new(""), depth, &mut entries).await?;
|
||||
|
||||
if entries.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let start_index = offset - 1;
|
||||
if start_index >= entries.len() {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"offset exceeds directory entry count".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let remaining_entries = entries.len() - start_index;
|
||||
let capped_limit = limit.min(remaining_entries);
|
||||
let end_index = start_index + capped_limit;
|
||||
let mut selected_entries = entries[start_index..end_index].to_vec();
|
||||
selected_entries.sort_unstable_by(|a, b| a.name.cmp(&b.name));
|
||||
let mut formatted = Vec::with_capacity(selected_entries.len());
|
||||
|
||||
for entry in &selected_entries {
|
||||
formatted.push(format_entry_line(entry));
|
||||
}
|
||||
|
||||
if end_index < entries.len() {
|
||||
formatted.push(format!("More than {capped_limit} entries found"));
|
||||
}
|
||||
|
||||
Ok(formatted)
|
||||
}
|
||||
|
||||
async fn collect_entries(
|
||||
dir_path: &Path,
|
||||
relative_prefix: &Path,
|
||||
depth: usize,
|
||||
entries: &mut Vec<DirEntry>,
|
||||
) -> Result<(), FunctionCallError> {
|
||||
let mut queue = VecDeque::new();
|
||||
queue.push_back((dir_path.to_path_buf(), relative_prefix.to_path_buf(), depth));
|
||||
|
||||
while let Some((current_dir, prefix, remaining_depth)) = queue.pop_front() {
|
||||
let mut read_dir = fs::read_dir(¤t_dir).await.map_err(|err| {
|
||||
FunctionCallError::RespondToModel(format!("failed to read directory: {err}"))
|
||||
})?;
|
||||
|
||||
let mut dir_entries = Vec::new();
|
||||
|
||||
while let Some(entry) = read_dir.next_entry().await.map_err(|err| {
|
||||
FunctionCallError::RespondToModel(format!("failed to read directory: {err}"))
|
||||
})? {
|
||||
let file_type = entry.file_type().await.map_err(|err| {
|
||||
FunctionCallError::RespondToModel(format!("failed to inspect entry: {err}"))
|
||||
})?;
|
||||
|
||||
let file_name = entry.file_name();
|
||||
let relative_path = if prefix.as_os_str().is_empty() {
|
||||
PathBuf::from(&file_name)
|
||||
} else {
|
||||
prefix.join(&file_name)
|
||||
};
|
||||
|
||||
let display_name = format_entry_component(&file_name);
|
||||
let display_depth = prefix.components().count();
|
||||
let sort_key = format_entry_name(&relative_path);
|
||||
let kind = DirEntryKind::from(&file_type);
|
||||
dir_entries.push((
|
||||
entry.path(),
|
||||
relative_path,
|
||||
kind,
|
||||
DirEntry {
|
||||
name: sort_key,
|
||||
display_name,
|
||||
depth: display_depth,
|
||||
kind,
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
dir_entries.sort_unstable_by(|a, b| a.3.name.cmp(&b.3.name));
|
||||
|
||||
for (entry_path, relative_path, kind, dir_entry) in dir_entries {
|
||||
if kind == DirEntryKind::Directory && remaining_depth > 1 {
|
||||
queue.push_back((entry_path, relative_path, remaining_depth - 1));
|
||||
}
|
||||
entries.push(dir_entry);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn format_entry_name(path: &Path) -> String {
|
||||
let normalized = path.to_string_lossy().replace("\\", "/");
|
||||
if normalized.len() > MAX_ENTRY_LENGTH {
|
||||
take_bytes_at_char_boundary(&normalized, MAX_ENTRY_LENGTH).to_string()
|
||||
} else {
|
||||
normalized
|
||||
}
|
||||
}
|
||||
|
||||
fn format_entry_component(name: &OsStr) -> String {
|
||||
let normalized = name.to_string_lossy();
|
||||
if normalized.len() > MAX_ENTRY_LENGTH {
|
||||
take_bytes_at_char_boundary(&normalized, MAX_ENTRY_LENGTH).to_string()
|
||||
} else {
|
||||
normalized.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn format_entry_line(entry: &DirEntry) -> String {
|
||||
let indent = " ".repeat(entry.depth * INDENTATION_SPACES);
|
||||
let mut name = entry.display_name.clone();
|
||||
match entry.kind {
|
||||
DirEntryKind::Directory => name.push('/'),
|
||||
DirEntryKind::Symlink => name.push('@'),
|
||||
DirEntryKind::Other => name.push('?'),
|
||||
DirEntryKind::File => {}
|
||||
}
|
||||
format!("{indent}{name}")
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct DirEntry {
|
||||
name: String,
|
||||
display_name: String,
|
||||
depth: usize,
|
||||
kind: DirEntryKind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum DirEntryKind {
|
||||
Directory,
|
||||
File,
|
||||
Symlink,
|
||||
Other,
|
||||
}
|
||||
|
||||
impl From<&FileType> for DirEntryKind {
|
||||
fn from(file_type: &FileType) -> Self {
|
||||
if file_type.is_symlink() {
|
||||
DirEntryKind::Symlink
|
||||
} else if file_type.is_dir() {
|
||||
DirEntryKind::Directory
|
||||
} else if file_type.is_file() {
|
||||
DirEntryKind::File
|
||||
} else {
|
||||
DirEntryKind::Other
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn lists_directory_entries() {
|
||||
let temp = tempdir().expect("create tempdir");
|
||||
let dir_path = temp.path();
|
||||
|
||||
let sub_dir = dir_path.join("nested");
|
||||
tokio::fs::create_dir(&sub_dir)
|
||||
.await
|
||||
.expect("create sub dir");
|
||||
|
||||
let deeper_dir = sub_dir.join("deeper");
|
||||
tokio::fs::create_dir(&deeper_dir)
|
||||
.await
|
||||
.expect("create deeper dir");
|
||||
|
||||
tokio::fs::write(dir_path.join("entry.txt"), b"content")
|
||||
.await
|
||||
.expect("write file");
|
||||
tokio::fs::write(sub_dir.join("child.txt"), b"child")
|
||||
.await
|
||||
.expect("write child");
|
||||
tokio::fs::write(deeper_dir.join("grandchild.txt"), b"grandchild")
|
||||
.await
|
||||
.expect("write grandchild");
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::symlink;
|
||||
let link_path = dir_path.join("link");
|
||||
symlink(dir_path.join("entry.txt"), &link_path).expect("create symlink");
|
||||
}
|
||||
|
||||
let entries = list_dir_slice(dir_path, 1, 20, 3)
|
||||
.await
|
||||
.expect("list directory");
|
||||
|
||||
#[cfg(unix)]
|
||||
let expected = vec![
|
||||
"entry.txt".to_string(),
|
||||
"link@".to_string(),
|
||||
"nested/".to_string(),
|
||||
" child.txt".to_string(),
|
||||
" deeper/".to_string(),
|
||||
" grandchild.txt".to_string(),
|
||||
];
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let expected = vec![
|
||||
"entry.txt".to_string(),
|
||||
"nested/".to_string(),
|
||||
" child.txt".to_string(),
|
||||
" deeper/".to_string(),
|
||||
" grandchild.txt".to_string(),
|
||||
];
|
||||
|
||||
assert_eq!(entries, expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn errors_when_offset_exceeds_entries() {
|
||||
let temp = tempdir().expect("create tempdir");
|
||||
let dir_path = temp.path();
|
||||
tokio::fs::create_dir(dir_path.join("nested"))
|
||||
.await
|
||||
.expect("create sub dir");
|
||||
|
||||
let err = list_dir_slice(dir_path, 10, 1, 2)
|
||||
.await
|
||||
.expect_err("offset exceeds entries");
|
||||
assert_eq!(
|
||||
err,
|
||||
FunctionCallError::RespondToModel("offset exceeds directory entry count".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn respects_depth_parameter() {
|
||||
let temp = tempdir().expect("create tempdir");
|
||||
let dir_path = temp.path();
|
||||
let nested = dir_path.join("nested");
|
||||
let deeper = nested.join("deeper");
|
||||
tokio::fs::create_dir(&nested).await.expect("create nested");
|
||||
tokio::fs::create_dir(&deeper).await.expect("create deeper");
|
||||
tokio::fs::write(dir_path.join("root.txt"), b"root")
|
||||
.await
|
||||
.expect("write root");
|
||||
tokio::fs::write(nested.join("child.txt"), b"child")
|
||||
.await
|
||||
.expect("write nested");
|
||||
tokio::fs::write(deeper.join("grandchild.txt"), b"deep")
|
||||
.await
|
||||
.expect("write deeper");
|
||||
|
||||
let entries_depth_one = list_dir_slice(dir_path, 1, 10, 1)
|
||||
.await
|
||||
.expect("list depth 1");
|
||||
assert_eq!(
|
||||
entries_depth_one,
|
||||
vec!["nested/".to_string(), "root.txt".to_string(),]
|
||||
);
|
||||
|
||||
let entries_depth_two = list_dir_slice(dir_path, 1, 20, 2)
|
||||
.await
|
||||
.expect("list depth 2");
|
||||
assert_eq!(
|
||||
entries_depth_two,
|
||||
vec![
|
||||
"nested/".to_string(),
|
||||
" child.txt".to_string(),
|
||||
" deeper/".to_string(),
|
||||
"root.txt".to_string(),
|
||||
]
|
||||
);
|
||||
|
||||
let entries_depth_three = list_dir_slice(dir_path, 1, 30, 3)
|
||||
.await
|
||||
.expect("list depth 3");
|
||||
assert_eq!(
|
||||
entries_depth_three,
|
||||
vec![
|
||||
"nested/".to_string(),
|
||||
" child.txt".to_string(),
|
||||
" deeper/".to_string(),
|
||||
" grandchild.txt".to_string(),
|
||||
"root.txt".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handles_large_limit_without_overflow() {
|
||||
let temp = tempdir().expect("create tempdir");
|
||||
let dir_path = temp.path();
|
||||
tokio::fs::write(dir_path.join("alpha.txt"), b"alpha")
|
||||
.await
|
||||
.expect("write alpha");
|
||||
tokio::fs::write(dir_path.join("beta.txt"), b"beta")
|
||||
.await
|
||||
.expect("write beta");
|
||||
tokio::fs::write(dir_path.join("gamma.txt"), b"gamma")
|
||||
.await
|
||||
.expect("write gamma");
|
||||
|
||||
let entries = list_dir_slice(dir_path, 2, usize::MAX, 1)
|
||||
.await
|
||||
.expect("list without overflow");
|
||||
assert_eq!(
|
||||
entries,
|
||||
vec!["beta.txt".to_string(), "gamma.txt".to_string(),]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn indicates_truncated_results() {
|
||||
let temp = tempdir().expect("create tempdir");
|
||||
let dir_path = temp.path();
|
||||
|
||||
for idx in 0..40 {
|
||||
let file = dir_path.join(format!("file_{idx:02}.txt"));
|
||||
tokio::fs::write(file, b"content")
|
||||
.await
|
||||
.expect("write file");
|
||||
}
|
||||
|
||||
let entries = list_dir_slice(dir_path, 1, 25, 1)
|
||||
.await
|
||||
.expect("list directory");
|
||||
assert_eq!(entries.len(), 26);
|
||||
assert_eq!(
|
||||
entries.last(),
|
||||
Some(&"More than 25 entries found".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bfs_truncation() -> anyhow::Result<()> {
|
||||
let temp = tempdir()?;
|
||||
let dir_path = temp.path();
|
||||
let nested = dir_path.join("nested");
|
||||
let deeper = nested.join("deeper");
|
||||
tokio::fs::create_dir(&nested).await?;
|
||||
tokio::fs::create_dir(&deeper).await?;
|
||||
tokio::fs::write(dir_path.join("root.txt"), b"root").await?;
|
||||
tokio::fs::write(nested.join("child.txt"), b"child").await?;
|
||||
tokio::fs::write(deeper.join("grandchild.txt"), b"deep").await?;
|
||||
|
||||
let entries_depth_three = list_dir_slice(dir_path, 1, 3, 3).await?;
|
||||
assert_eq!(
|
||||
entries_depth_three,
|
||||
vec![
|
||||
"nested/".to_string(),
|
||||
" child.txt".to_string(),
|
||||
"root.txt".to_string(),
|
||||
"More than 3 entries found".to_string()
|
||||
]
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
pub mod apply_patch;
|
||||
mod exec_stream;
|
||||
mod list_dir;
|
||||
mod mcp;
|
||||
mod plan;
|
||||
mod read_file;
|
||||
@@ -13,7 +12,6 @@ pub use plan::PLAN_TOOL;
|
||||
|
||||
pub use apply_patch::ApplyPatchHandler;
|
||||
pub use exec_stream::ExecStreamHandler;
|
||||
pub use list_dir::ListDirHandler;
|
||||
pub use mcp::McpHandler;
|
||||
pub use plan::PlanHandler;
|
||||
pub use read_file::ReadFileHandler;
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::RwLock;
|
||||
use tokio_util::either::Either;
|
||||
use tokio_util::task::AbortOnDropHandle;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
@@ -13,13 +11,20 @@ use crate::tools::router::ToolCall;
|
||||
use crate::tools::router::ToolRouter;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
|
||||
use crate::codex::ProcessedResponseItem;
|
||||
|
||||
struct PendingToolCall {
|
||||
index: usize,
|
||||
handle: JoinHandle<Result<ResponseInputItem, FunctionCallError>>,
|
||||
}
|
||||
|
||||
pub(crate) struct ToolCallRuntime {
|
||||
router: Arc<ToolRouter>,
|
||||
session: Arc<Session>,
|
||||
turn_context: Arc<TurnContext>,
|
||||
tracker: SharedTurnDiffTracker,
|
||||
sub_id: String,
|
||||
parallel_execution: Arc<RwLock<()>>,
|
||||
pending_calls: Vec<PendingToolCall>,
|
||||
}
|
||||
|
||||
impl ToolCallRuntime {
|
||||
@@ -36,45 +41,97 @@ impl ToolCallRuntime {
|
||||
turn_context,
|
||||
tracker,
|
||||
sub_id,
|
||||
parallel_execution: Arc::new(RwLock::new(())),
|
||||
pending_calls: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn handle_tool_call(
|
||||
&self,
|
||||
pub(crate) async fn handle_tool_call(
|
||||
&mut self,
|
||||
call: ToolCall,
|
||||
) -> impl std::future::Future<Output = Result<ResponseInputItem, CodexErr>> {
|
||||
output_index: usize,
|
||||
output: &mut [ProcessedResponseItem],
|
||||
) -> Result<(), CodexErr> {
|
||||
let supports_parallel = self.router.tool_supports_parallel(&call.tool_name);
|
||||
if supports_parallel {
|
||||
self.spawn_parallel(call, output_index);
|
||||
} else {
|
||||
self.resolve_pending(output).await?;
|
||||
let response = self.dispatch_serial(call).await?;
|
||||
let slot = output.get_mut(output_index).ok_or_else(|| {
|
||||
CodexErr::Fatal(format!("tool output index {output_index} out of bounds"))
|
||||
})?;
|
||||
slot.response = Some(response);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn abort_all(&mut self) {
|
||||
while let Some(pending) = self.pending_calls.pop() {
|
||||
pending.handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_pending(
|
||||
&mut self,
|
||||
output: &mut [ProcessedResponseItem],
|
||||
) -> Result<(), CodexErr> {
|
||||
while let Some(PendingToolCall { index, handle }) = self.pending_calls.pop() {
|
||||
match handle.await {
|
||||
Ok(Ok(response)) => {
|
||||
if let Some(slot) = output.get_mut(index) {
|
||||
slot.response = Some(response);
|
||||
}
|
||||
}
|
||||
Ok(Err(FunctionCallError::Fatal(message))) => {
|
||||
self.abort_all();
|
||||
return Err(CodexErr::Fatal(message));
|
||||
}
|
||||
Ok(Err(other)) => {
|
||||
self.abort_all();
|
||||
return Err(CodexErr::Fatal(other.to_string()));
|
||||
}
|
||||
Err(join_err) => {
|
||||
self.abort_all();
|
||||
return Err(CodexErr::Fatal(format!(
|
||||
"tool task failed to join: {join_err}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn spawn_parallel(&mut self, call: ToolCall, index: usize) {
|
||||
let router = Arc::clone(&self.router);
|
||||
let session = Arc::clone(&self.session);
|
||||
let turn = Arc::clone(&self.turn_context);
|
||||
let tracker = Arc::clone(&self.tracker);
|
||||
let sub_id = self.sub_id.clone();
|
||||
let lock = Arc::clone(&self.parallel_execution);
|
||||
let handle = tokio::spawn(async move {
|
||||
router
|
||||
.dispatch_tool_call(session, turn, tracker, sub_id, call)
|
||||
.await
|
||||
});
|
||||
self.pending_calls.push(PendingToolCall { index, handle });
|
||||
}
|
||||
|
||||
let handle: AbortOnDropHandle<Result<ResponseInputItem, FunctionCallError>> =
|
||||
AbortOnDropHandle::new(tokio::spawn(async move {
|
||||
let _guard = if supports_parallel {
|
||||
Either::Left(lock.read().await)
|
||||
} else {
|
||||
Either::Right(lock.write().await)
|
||||
};
|
||||
|
||||
router
|
||||
.dispatch_tool_call(session, turn, tracker, sub_id, call)
|
||||
.await
|
||||
}));
|
||||
|
||||
async move {
|
||||
match handle.await {
|
||||
Ok(Ok(response)) => Ok(response),
|
||||
Ok(Err(FunctionCallError::Fatal(message))) => Err(CodexErr::Fatal(message)),
|
||||
Ok(Err(other)) => Err(CodexErr::Fatal(other.to_string())),
|
||||
Err(err) => Err(CodexErr::Fatal(format!(
|
||||
"tool task failed to receive: {err:?}"
|
||||
))),
|
||||
}
|
||||
async fn dispatch_serial(&self, call: ToolCall) -> Result<ResponseInputItem, CodexErr> {
|
||||
match self
|
||||
.router
|
||||
.dispatch_tool_call(
|
||||
Arc::clone(&self.session),
|
||||
Arc::clone(&self.turn_context),
|
||||
Arc::clone(&self.tracker),
|
||||
self.sub_id.clone(),
|
||||
call,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => Ok(response),
|
||||
Err(FunctionCallError::Fatal(message)) => Err(CodexErr::Fatal(message)),
|
||||
Err(other) => Err(CodexErr::Fatal(other.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,51 +356,6 @@ fn create_read_file_tool() -> ToolSpec {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn create_list_dir_tool() -> ToolSpec {
|
||||
let mut properties = BTreeMap::new();
|
||||
properties.insert(
|
||||
"dir_path".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("Absolute path to the directory to list.".to_string()),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"offset".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some(
|
||||
"The entry number to start listing from. Must be 1 or greater.".to_string(),
|
||||
),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"limit".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some("The maximum number of entries to return.".to_string()),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"depth".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some(
|
||||
"The maximum directory depth to traverse. Must be 1 or greater.".to_string(),
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "list_dir".to_string(),
|
||||
description:
|
||||
"Lists entries in a local directory with 1-indexed entry numbers and simple type labels."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["dir_path".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
})
|
||||
}
|
||||
/// TODO(dylan): deprecate once we get rid of json tool
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub(crate) struct ApplyPatchToolArgs {
|
||||
@@ -610,7 +565,6 @@ pub(crate) fn build_specs(
|
||||
use crate::exec_command::create_write_stdin_tool_for_responses_api;
|
||||
use crate::tools::handlers::ApplyPatchHandler;
|
||||
use crate::tools::handlers::ExecStreamHandler;
|
||||
use crate::tools::handlers::ListDirHandler;
|
||||
use crate::tools::handlers::McpHandler;
|
||||
use crate::tools::handlers::PlanHandler;
|
||||
use crate::tools::handlers::ReadFileHandler;
|
||||
@@ -686,16 +640,6 @@ pub(crate) fn build_specs(
|
||||
builder.register_handler("read_file", read_file_handler);
|
||||
}
|
||||
|
||||
if config
|
||||
.experimental_supported_tools
|
||||
.iter()
|
||||
.any(|tool| tool == "list_dir")
|
||||
{
|
||||
let list_dir_handler = Arc::new(ListDirHandler);
|
||||
builder.push_spec_with_parallel_support(create_list_dir_tool(), true);
|
||||
builder.register_handler("list_dir", list_dir_handler);
|
||||
}
|
||||
|
||||
if config
|
||||
.experimental_supported_tools
|
||||
.iter()
|
||||
@@ -842,7 +786,6 @@ mod tests {
|
||||
|
||||
assert!(!find_tool(&tools, "unified_exec").supports_parallel_tool_calls);
|
||||
assert!(find_tool(&tools, "read_file").supports_parallel_tool_calls);
|
||||
assert!(find_tool(&tools, "list_dir").supports_parallel_tool_calls);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -870,7 +813,6 @@ mod tests {
|
||||
.iter()
|
||||
.any(|tool| tool_name(&tool.spec) == "read_file")
|
||||
);
|
||||
assert!(tools.iter().any(|tool| tool_name(&tool.spec) == "list_dir"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
467
codex-rs/core/src/windows_appcontainer.rs
Normal file
467
codex-rs/core/src/windows_appcontainer.rs
Normal file
@@ -0,0 +1,467 @@
|
||||
#![cfg(windows)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use tokio::process::Child;
|
||||
use tracing::trace;
|
||||
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::spawn::StdioPolicy;
|
||||
|
||||
#[cfg(feature = "windows_appcontainer_command_ext")]
|
||||
mod imp {
|
||||
use super::*;
|
||||
|
||||
use std::ffi::OsStr;
|
||||
use std::ffi::c_void;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::ptr::null_mut;
|
||||
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::spawn::CODEX_SANDBOX_ENV_VAR;
|
||||
use crate::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
|
||||
|
||||
use windows::Win32::Foundation::ERROR_ALREADY_EXISTS;
|
||||
use windows::Win32::Foundation::ERROR_SUCCESS;
|
||||
use windows::Win32::Foundation::GetLastError;
|
||||
use windows::Win32::Foundation::HANDLE;
|
||||
use windows::Win32::Foundation::HLOCAL;
|
||||
use windows::Win32::Foundation::LocalFree;
|
||||
use windows::Win32::Foundation::WIN32_ERROR;
|
||||
use windows::Win32::Security::ACL;
|
||||
use windows::Win32::Security::Authorization::ConvertStringSidToSidW;
|
||||
use windows::Win32::Security::Authorization::EXPLICIT_ACCESS_W;
|
||||
use windows::Win32::Security::Authorization::GetNamedSecurityInfoW;
|
||||
use windows::Win32::Security::Authorization::SE_FILE_OBJECT;
|
||||
use windows::Win32::Security::Authorization::SET_ACCESS;
|
||||
use windows::Win32::Security::Authorization::SetEntriesInAclW;
|
||||
use windows::Win32::Security::Authorization::SetNamedSecurityInfoW;
|
||||
use windows::Win32::Security::Authorization::TRUSTEE_IS_SID;
|
||||
use windows::Win32::Security::Authorization::TRUSTEE_IS_UNKNOWN;
|
||||
use windows::Win32::Security::Authorization::TRUSTEE_W;
|
||||
use windows::Win32::Security::DACL_SECURITY_INFORMATION;
|
||||
use windows::Win32::Security::FreeSid;
|
||||
use windows::Win32::Security::Isolation::CreateAppContainerProfile;
|
||||
use windows::Win32::Security::Isolation::DeriveAppContainerSidFromAppContainerName;
|
||||
use windows::Win32::Security::OBJECT_INHERIT_ACE;
|
||||
use windows::Win32::Security::PSECURITY_DESCRIPTOR;
|
||||
use windows::Win32::Security::PSID;
|
||||
use windows::Win32::Security::SECURITY_CAPABILITIES;
|
||||
use windows::Win32::Security::SID_AND_ATTRIBUTES;
|
||||
use windows::Win32::Security::SUB_CONTAINERS_AND_OBJECTS_INHERIT;
|
||||
use windows::Win32::Storage::FileSystem::FILE_GENERIC_EXECUTE;
|
||||
use windows::Win32::Storage::FileSystem::FILE_GENERIC_READ;
|
||||
use windows::Win32::Storage::FileSystem::FILE_GENERIC_WRITE;
|
||||
use windows::Win32::System::Memory::GetProcessHeap;
|
||||
use windows::Win32::System::Memory::HEAP_FLAGS;
|
||||
use windows::Win32::System::Memory::HEAP_ZERO_MEMORY;
|
||||
use windows::Win32::System::Memory::HeapAlloc;
|
||||
use windows::Win32::System::Memory::HeapFree;
|
||||
use windows::Win32::System::Threading::DeleteProcThreadAttributeList;
|
||||
use windows::Win32::System::Threading::EXTENDED_STARTUPINFO_PRESENT;
|
||||
use windows::Win32::System::Threading::InitializeProcThreadAttributeList;
|
||||
use windows::Win32::System::Threading::LPPROC_THREAD_ATTRIBUTE_LIST;
|
||||
use windows::Win32::System::Threading::PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES;
|
||||
use windows::Win32::System::Threading::UpdateProcThreadAttribute;
|
||||
use windows::core::PCWSTR;
|
||||
use windows::core::PWSTR;
|
||||
|
||||
const WINDOWS_APPCONTAINER_PROFILE_NAME: &str = "codex_appcontainer";
|
||||
const WINDOWS_APPCONTAINER_PROFILE_DESC: &str = "Codex Windows AppContainer profile";
|
||||
const WINDOWS_APPCONTAINER_SANDBOX_VALUE: &str = "windows_appcontainer";
|
||||
const INTERNET_CLIENT_SID: &str = "S-1-15-3-1";
|
||||
const PRIVATE_NETWORK_CLIENT_SID: &str = "S-1-15-3-3";
|
||||
|
||||
pub async fn spawn_command_under_windows_appcontainer(
|
||||
command: Vec<String>,
|
||||
command_cwd: PathBuf,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
sandbox_policy_cwd: &Path,
|
||||
stdio_policy: StdioPolicy,
|
||||
mut env: HashMap<String, String>,
|
||||
) -> io::Result<Child> {
|
||||
trace!("windows appcontainer sandbox command = {:?}", command);
|
||||
|
||||
let (program, rest) = command
|
||||
.split_first()
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "command args are empty"))?;
|
||||
|
||||
ensure_appcontainer_profile()?;
|
||||
let mut sid = derive_appcontainer_sid()?;
|
||||
let mut capability_sids = build_capabilities(sandbox_policy)?;
|
||||
let mut attribute_list = AttributeList::new(&mut sid, &mut capability_sids)?;
|
||||
|
||||
configure_writable_roots(sandbox_policy, sandbox_policy_cwd, sid.sid())?;
|
||||
configure_writable_roots_for_command_cwd(&command_cwd, sid.sid())?;
|
||||
|
||||
if !sandbox_policy.has_full_network_access() {
|
||||
env.insert(
|
||||
CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR.to_string(),
|
||||
"1".to_string(),
|
||||
);
|
||||
}
|
||||
env.insert(
|
||||
CODEX_SANDBOX_ENV_VAR.to_string(),
|
||||
WINDOWS_APPCONTAINER_SANDBOX_VALUE.to_string(),
|
||||
);
|
||||
|
||||
let mut cmd = Command::new(program);
|
||||
cmd.args(rest);
|
||||
cmd.current_dir(command_cwd);
|
||||
cmd.env_clear();
|
||||
cmd.envs(env);
|
||||
apply_stdio_policy(&mut cmd, stdio_policy);
|
||||
cmd.kill_on_drop(true);
|
||||
|
||||
unsafe {
|
||||
let std_cmd = cmd.as_std_mut();
|
||||
std_cmd.creation_flags(EXTENDED_STARTUPINFO_PRESENT.0);
|
||||
std_cmd.raw_attribute_list(attribute_list.as_mut_ptr().0);
|
||||
}
|
||||
|
||||
let child = cmd.spawn();
|
||||
drop(attribute_list);
|
||||
child
|
||||
}
|
||||
|
||||
fn apply_stdio_policy(cmd: &mut Command, policy: StdioPolicy) {
|
||||
match policy {
|
||||
StdioPolicy::RedirectForShellTool => {
|
||||
cmd.stdin(std::process::Stdio::null());
|
||||
cmd.stdout(std::process::Stdio::piped());
|
||||
cmd.stderr(std::process::Stdio::piped());
|
||||
}
|
||||
StdioPolicy::Inherit => {
|
||||
cmd.stdin(std::process::Stdio::inherit());
|
||||
cmd.stdout(std::process::Stdio::inherit());
|
||||
cmd.stderr(std::process::Stdio::inherit());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn to_wide<S: AsRef<OsStr>>(s: S) -> Vec<u16> {
|
||||
s.as_ref().encode_wide().chain(std::iter::once(0)).collect()
|
||||
}
|
||||
|
||||
fn ensure_appcontainer_profile() -> io::Result<()> {
|
||||
unsafe {
|
||||
let name = to_wide(WINDOWS_APPCONTAINER_PROFILE_NAME);
|
||||
let desc = to_wide(WINDOWS_APPCONTAINER_PROFILE_DESC);
|
||||
match CreateAppContainerProfile(
|
||||
PCWSTR(name.as_ptr()),
|
||||
PCWSTR(name.as_ptr()),
|
||||
PCWSTR(desc.as_ptr()),
|
||||
None,
|
||||
) {
|
||||
Ok(profile_sid) => {
|
||||
if !profile_sid.is_invalid() {
|
||||
FreeSid(profile_sid);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
let already_exists = WIN32_ERROR::from(ERROR_ALREADY_EXISTS);
|
||||
if GetLastError() != already_exists {
|
||||
return Err(io::Error::from_raw_os_error(error.code().0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct SidHandle {
|
||||
ptr: PSID,
|
||||
}
|
||||
|
||||
impl SidHandle {
|
||||
fn sid(&self) -> PSID {
|
||||
self.ptr
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SidHandle {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
if !self.ptr.is_invalid() {
|
||||
FreeSid(self.ptr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn derive_appcontainer_sid() -> io::Result<SidHandle> {
|
||||
unsafe {
|
||||
let name = to_wide(WINDOWS_APPCONTAINER_PROFILE_NAME);
|
||||
let sid = DeriveAppContainerSidFromAppContainerName(PCWSTR(name.as_ptr()))
|
||||
.map_err(|e| io::Error::from_raw_os_error(e.code().0))?;
|
||||
Ok(SidHandle { ptr: sid })
|
||||
}
|
||||
}
|
||||
|
||||
struct CapabilitySid {
|
||||
sid: PSID,
|
||||
}
|
||||
|
||||
impl CapabilitySid {
|
||||
fn new_from_string(value: &str) -> io::Result<Self> {
|
||||
unsafe {
|
||||
let mut sid_ptr = PSID::default();
|
||||
let wide = to_wide(value);
|
||||
ConvertStringSidToSidW(PCWSTR(wide.as_ptr()), &mut sid_ptr)
|
||||
.map_err(|e| io::Error::from_raw_os_error(e.code().0))?;
|
||||
Ok(Self { sid: sid_ptr })
|
||||
}
|
||||
}
|
||||
|
||||
fn sid_and_attributes(&self) -> SID_AND_ATTRIBUTES {
|
||||
SID_AND_ATTRIBUTES {
|
||||
Sid: self.sid,
|
||||
Attributes: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CapabilitySid {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
if !self.sid.is_invalid() {
|
||||
let _ = LocalFree(HLOCAL(self.sid.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_capabilities(policy: &SandboxPolicy) -> io::Result<Vec<CapabilitySid>> {
|
||||
if policy.has_full_network_access() {
|
||||
Ok(vec![
|
||||
CapabilitySid::new_from_string(INTERNET_CLIENT_SID)?,
|
||||
CapabilitySid::new_from_string(PRIVATE_NETWORK_CLIENT_SID)?,
|
||||
])
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
struct AttributeList<'a> {
|
||||
heap: HANDLE,
|
||||
buffer: *mut c_void,
|
||||
list: LPPROC_THREAD_ATTRIBUTE_LIST,
|
||||
sec_caps: SECURITY_CAPABILITIES,
|
||||
sid_and_attributes: Vec<SID_AND_ATTRIBUTES>,
|
||||
#[allow(dead_code)]
|
||||
sid: &'a mut SidHandle,
|
||||
#[allow(dead_code)]
|
||||
capabilities: &'a mut Vec<CapabilitySid>,
|
||||
}
|
||||
|
||||
impl<'a> AttributeList<'a> {
|
||||
fn new(sid: &'a mut SidHandle, caps: &'a mut Vec<CapabilitySid>) -> io::Result<Self> {
|
||||
unsafe {
|
||||
let mut list_size = 0usize;
|
||||
let _ = InitializeProcThreadAttributeList(
|
||||
LPPROC_THREAD_ATTRIBUTE_LIST::default(),
|
||||
1,
|
||||
0,
|
||||
&mut list_size,
|
||||
);
|
||||
let heap =
|
||||
GetProcessHeap().map_err(|e| io::Error::from_raw_os_error(e.code().0))?;
|
||||
let buffer = HeapAlloc(heap, HEAP_ZERO_MEMORY, list_size);
|
||||
if buffer.is_null() {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
let list = LPPROC_THREAD_ATTRIBUTE_LIST(buffer);
|
||||
InitializeProcThreadAttributeList(list, 1, 0, &mut list_size)
|
||||
.map_err(|e| io::Error::from_raw_os_error(e.code().0))?;
|
||||
|
||||
let mut sid_and_attributes: Vec<SID_AND_ATTRIBUTES> =
|
||||
caps.iter().map(CapabilitySid::sid_and_attributes).collect();
|
||||
|
||||
let mut sec_caps = SECURITY_CAPABILITIES {
|
||||
AppContainerSid: sid.sid(),
|
||||
Capabilities: if sid_and_attributes.is_empty() {
|
||||
null_mut()
|
||||
} else {
|
||||
sid_and_attributes.as_mut_ptr()
|
||||
},
|
||||
CapabilityCount: sid_and_attributes.len() as u32,
|
||||
Reserved: 0,
|
||||
};
|
||||
|
||||
UpdateProcThreadAttribute(
|
||||
list,
|
||||
0,
|
||||
PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES as usize,
|
||||
Some(&mut sec_caps as *mut _ as *const std::ffi::c_void),
|
||||
std::mem::size_of::<SECURITY_CAPABILITIES>(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.map_err(|e| io::Error::from_raw_os_error(e.code().0))?;
|
||||
|
||||
Ok(Self {
|
||||
heap,
|
||||
buffer,
|
||||
list,
|
||||
sec_caps,
|
||||
sid_and_attributes,
|
||||
sid,
|
||||
capabilities: caps,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn as_mut_ptr(&mut self) -> LPPROC_THREAD_ATTRIBUTE_LIST {
|
||||
self.list
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AttributeList<'_> {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
if !self.list.is_invalid() {
|
||||
DeleteProcThreadAttributeList(self.list);
|
||||
}
|
||||
if !self.heap.is_invalid() && !self.buffer.is_null() {
|
||||
let _ = HeapFree(self.heap, HEAP_FLAGS(0), Some(self.buffer));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn configure_writable_roots(
|
||||
policy: &SandboxPolicy,
|
||||
sandbox_policy_cwd: &Path,
|
||||
sid: PSID,
|
||||
) -> io::Result<()> {
|
||||
match policy {
|
||||
SandboxPolicy::DangerFullAccess => Ok(()),
|
||||
SandboxPolicy::ReadOnly => grant_path_with_flags(sandbox_policy_cwd, sid, false),
|
||||
SandboxPolicy::WorkspaceWrite { .. } => {
|
||||
let roots = policy.get_writable_roots_with_cwd(sandbox_policy_cwd);
|
||||
for writable in roots {
|
||||
grant_path_with_flags(&writable.root, sid, true)?;
|
||||
for ro in writable.read_only_subpaths {
|
||||
grant_path_with_flags(&ro, sid, false)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn configure_writable_roots_for_command_cwd(command_cwd: &Path, sid: PSID) -> io::Result<()> {
|
||||
grant_path_with_flags(command_cwd, sid, true)
|
||||
}
|
||||
|
||||
fn grant_path_with_flags(path: &Path, sid: PSID, write: bool) -> io::Result<()> {
|
||||
if !path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let wide = to_wide(path.as_os_str());
|
||||
unsafe {
|
||||
let mut existing_dacl: *mut ACL = null_mut();
|
||||
let mut security_descriptor = PSECURITY_DESCRIPTOR::default();
|
||||
let status = GetNamedSecurityInfoW(
|
||||
PCWSTR(wide.as_ptr()),
|
||||
SE_FILE_OBJECT,
|
||||
DACL_SECURITY_INFORMATION,
|
||||
None,
|
||||
None,
|
||||
Some(&mut existing_dacl),
|
||||
None,
|
||||
&mut security_descriptor,
|
||||
);
|
||||
if status != WIN32_ERROR::from(ERROR_SUCCESS) {
|
||||
if !security_descriptor.is_invalid() {
|
||||
let _ = LocalFree(HLOCAL(security_descriptor.0));
|
||||
}
|
||||
return Err(io::Error::from_raw_os_error(status.0 as i32));
|
||||
}
|
||||
|
||||
let permissions = if write {
|
||||
(FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE).0
|
||||
} else {
|
||||
(FILE_GENERIC_READ | FILE_GENERIC_EXECUTE).0
|
||||
};
|
||||
let explicit = EXPLICIT_ACCESS_W {
|
||||
grfAccessPermissions: permissions,
|
||||
grfAccessMode: SET_ACCESS,
|
||||
grfInheritance: (SUB_CONTAINERS_AND_OBJECTS_INHERIT | OBJECT_INHERIT_ACE).0,
|
||||
Trustee: TRUSTEE_W {
|
||||
TrusteeForm: TRUSTEE_IS_SID,
|
||||
TrusteeType: TRUSTEE_IS_UNKNOWN,
|
||||
ptstrName: PWSTR(sid.0.cast()),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
|
||||
let explicit_entries = [explicit];
|
||||
let mut new_dacl: *mut ACL = null_mut();
|
||||
let add_result =
|
||||
SetEntriesInAclW(Some(&explicit_entries), Some(existing_dacl), &mut new_dacl);
|
||||
if add_result != WIN32_ERROR::from(ERROR_SUCCESS) {
|
||||
if !new_dacl.is_null() {
|
||||
let _ = LocalFree(HLOCAL(new_dacl.cast()));
|
||||
}
|
||||
if !security_descriptor.is_invalid() {
|
||||
let _ = LocalFree(HLOCAL(security_descriptor.0));
|
||||
}
|
||||
return Err(io::Error::from_raw_os_error(add_result.0 as i32));
|
||||
}
|
||||
|
||||
let set_result = SetNamedSecurityInfoW(
|
||||
PCWSTR(wide.as_ptr()),
|
||||
SE_FILE_OBJECT,
|
||||
DACL_SECURITY_INFORMATION,
|
||||
None,
|
||||
None,
|
||||
Some(new_dacl),
|
||||
None,
|
||||
);
|
||||
if set_result != WIN32_ERROR::from(ERROR_SUCCESS) {
|
||||
if !new_dacl.is_null() {
|
||||
let _ = LocalFree(HLOCAL(new_dacl.cast()));
|
||||
}
|
||||
if !security_descriptor.is_invalid() {
|
||||
let _ = LocalFree(HLOCAL(security_descriptor.0));
|
||||
}
|
||||
return Err(io::Error::from_raw_os_error(set_result.0 as i32));
|
||||
}
|
||||
|
||||
if !new_dacl.is_null() {
|
||||
let _ = LocalFree(HLOCAL(new_dacl.cast()));
|
||||
}
|
||||
if !security_descriptor.is_invalid() {
|
||||
let _ = LocalFree(HLOCAL(security_descriptor.0));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "windows_appcontainer_command_ext")]
|
||||
pub use imp::spawn_command_under_windows_appcontainer;
|
||||
|
||||
#[cfg(not(feature = "windows_appcontainer_command_ext"))]
|
||||
pub async fn spawn_command_under_windows_appcontainer(
|
||||
command: Vec<String>,
|
||||
command_cwd: PathBuf,
|
||||
_sandbox_policy: &SandboxPolicy,
|
||||
_sandbox_policy_cwd: &Path,
|
||||
_stdio_policy: StdioPolicy,
|
||||
_env: HashMap<String, String>,
|
||||
) -> io::Result<Child> {
|
||||
let _ = (command, command_cwd);
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"AppContainer sandboxing requires the `windows_appcontainer_command_ext` feature",
|
||||
))
|
||||
}
|
||||
@@ -1,105 +1,11 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde_json::Value;
|
||||
use wiremock::BodyPrintLimit;
|
||||
use wiremock::Match;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockBuilder;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::Respond;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path_regex;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResponseMock {
|
||||
requests: Arc<Mutex<Vec<ResponsesRequest>>>,
|
||||
}
|
||||
|
||||
impl ResponseMock {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
requests: Arc::new(Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn single_request(&self) -> ResponsesRequest {
|
||||
let requests = self.requests.lock().unwrap();
|
||||
if requests.len() != 1 {
|
||||
panic!("expected 1 request, got {}", requests.len());
|
||||
}
|
||||
requests.first().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn requests(&self) -> Vec<ResponsesRequest> {
|
||||
self.requests.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResponsesRequest(wiremock::Request);
|
||||
|
||||
impl ResponsesRequest {
|
||||
pub fn body_json(&self) -> Value {
|
||||
self.0.body_json().unwrap()
|
||||
}
|
||||
|
||||
pub fn input(&self) -> Vec<Value> {
|
||||
self.0.body_json::<Value>().unwrap()["input"]
|
||||
.as_array()
|
||||
.expect("input array not found in request")
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub fn function_call_output(&self, call_id: &str) -> Value {
|
||||
self.call_output(call_id, "function_call_output")
|
||||
}
|
||||
|
||||
pub fn custom_tool_call_output(&self, call_id: &str) -> Value {
|
||||
self.call_output(call_id, "custom_tool_call_output")
|
||||
}
|
||||
|
||||
pub fn call_output(&self, call_id: &str, call_type: &str) -> Value {
|
||||
self.input()
|
||||
.iter()
|
||||
.find(|item| {
|
||||
item.get("type").unwrap() == call_type && item.get("call_id").unwrap() == call_id
|
||||
})
|
||||
.cloned()
|
||||
.unwrap_or_else(|| panic!("function call output {call_id} item not found in request"))
|
||||
}
|
||||
|
||||
pub fn header(&self, name: &str) -> Option<String> {
|
||||
self.0
|
||||
.headers
|
||||
.get(name)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
pub fn path(&self) -> String {
|
||||
self.0.url.path().to_string()
|
||||
}
|
||||
|
||||
pub fn query_param(&self, name: &str) -> Option<String> {
|
||||
self.0
|
||||
.url
|
||||
.query_pairs()
|
||||
.find(|(k, _)| k == name)
|
||||
.map(|(_, v)| v.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl Match for ResponseMock {
|
||||
fn matches(&self, request: &wiremock::Request) -> bool {
|
||||
self.requests
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(ResponsesRequest(request.clone()));
|
||||
true
|
||||
}
|
||||
}
|
||||
use wiremock::matchers::path;
|
||||
|
||||
/// Build an SSE stream body from a list of JSON events.
|
||||
pub fn sse(events: Vec<Value>) -> String {
|
||||
@@ -255,40 +161,34 @@ pub fn sse_response(body: String) -> ResponseTemplate {
|
||||
.set_body_raw(body, "text/event-stream")
|
||||
}
|
||||
|
||||
fn base_mock() -> (MockBuilder, ResponseMock) {
|
||||
let response_mock = ResponseMock::new();
|
||||
let mock = Mock::given(method("POST"))
|
||||
.and(path_regex(".*/responses$"))
|
||||
.and(response_mock.clone());
|
||||
(mock, response_mock)
|
||||
}
|
||||
|
||||
pub async fn mount_sse_once_match<M>(server: &MockServer, matcher: M, body: String) -> ResponseMock
|
||||
pub async fn mount_sse_once_match<M>(server: &MockServer, matcher: M, body: String)
|
||||
where
|
||||
M: wiremock::Match + Send + Sync + 'static,
|
||||
{
|
||||
let (mock, response_mock) = base_mock();
|
||||
mock.and(matcher)
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/responses"))
|
||||
.and(matcher)
|
||||
.respond_with(sse_response(body))
|
||||
.up_to_n_times(1)
|
||||
.mount(server)
|
||||
.await;
|
||||
response_mock
|
||||
}
|
||||
|
||||
pub async fn mount_sse_once(server: &MockServer, body: String) -> ResponseMock {
|
||||
let (mock, response_mock) = base_mock();
|
||||
mock.respond_with(sse_response(body))
|
||||
.up_to_n_times(1)
|
||||
pub async fn mount_sse_once(server: &MockServer, body: String) {
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/responses"))
|
||||
.respond_with(sse_response(body))
|
||||
.expect(1)
|
||||
.mount(server)
|
||||
.await;
|
||||
response_mock
|
||||
}
|
||||
|
||||
pub async fn mount_sse(server: &MockServer, body: String) -> ResponseMock {
|
||||
let (mock, response_mock) = base_mock();
|
||||
mock.respond_with(sse_response(body)).mount(server).await;
|
||||
response_mock
|
||||
pub async fn mount_sse(server: &MockServer, body: String) {
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/responses"))
|
||||
.respond_with(sse_response(body))
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
pub async fn start_mock_server() -> MockServer {
|
||||
@@ -301,7 +201,7 @@ pub async fn start_mock_server() -> MockServer {
|
||||
/// Mounts a sequence of SSE response bodies and serves them in order for each
|
||||
/// POST to `/v1/responses`. Panics if more requests are received than bodies
|
||||
/// provided. Also asserts the exact number of expected calls.
|
||||
pub async fn mount_sse_sequence(server: &MockServer, bodies: Vec<String>) -> ResponseMock {
|
||||
pub async fn mount_sse_sequence(server: &MockServer, bodies: Vec<String>) {
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
@@ -328,11 +228,10 @@ pub async fn mount_sse_sequence(server: &MockServer, bodies: Vec<String>) -> Res
|
||||
responses: bodies,
|
||||
};
|
||||
|
||||
let (mock, response_mock) = base_mock();
|
||||
mock.respond_with(responder)
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/responses"))
|
||||
.respond_with(responder)
|
||||
.expect(num_calls as u64)
|
||||
.mount(server)
|
||||
.await;
|
||||
|
||||
response_mock
|
||||
}
|
||||
|
||||
@@ -106,12 +106,16 @@ async fn exec_cli_applies_experimental_instructions_file() {
|
||||
"data: {\"type\":\"response.created\",\"response\":{}}\n\n",
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"r1\"}}\n\n"
|
||||
);
|
||||
let resp_mock = core_test_support::responses::mount_sse_once_match(
|
||||
&server,
|
||||
path("/v1/responses"),
|
||||
sse.to_string(),
|
||||
)
|
||||
.await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/responses"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "text/event-stream")
|
||||
.set_body_raw(sse, "text/event-stream"),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
// Create a temporary instructions file with a unique marker we can assert
|
||||
// appears in the outbound request payload.
|
||||
@@ -160,8 +164,8 @@ async fn exec_cli_applies_experimental_instructions_file() {
|
||||
|
||||
// Inspect the captured request and verify our custom base instructions were
|
||||
// included in the `instructions` field.
|
||||
let request = resp_mock.single_request();
|
||||
let body = request.body_json();
|
||||
let request = &server.received_requests().await.unwrap()[0];
|
||||
let body = request.body_json::<serde_json::Value>().unwrap();
|
||||
let instructions = body
|
||||
.get("instructions")
|
||||
.and_then(|v| v.as_str())
|
||||
|
||||
@@ -223,9 +223,15 @@ async fn resume_includes_initial_messages_and_sends_prior_items() {
|
||||
|
||||
// Mock server that will receive the resumed request
|
||||
let server = MockServer::start().await;
|
||||
let resp_mock =
|
||||
responses::mount_sse_once_match(&server, path("/v1/responses"), sse_completed("resp1"))
|
||||
.await;
|
||||
let first = ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "text/event-stream")
|
||||
.set_body_raw(sse_completed("resp1"), "text/event-stream");
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/responses"))
|
||||
.respond_with(first)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
// Configure Codex to resume from our file
|
||||
let model_provider = ModelProviderInfo {
|
||||
@@ -271,8 +277,8 @@ async fn resume_includes_initial_messages_and_sends_prior_items() {
|
||||
.unwrap();
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
|
||||
let request = resp_mock.single_request();
|
||||
let request_body = request.body_json();
|
||||
let request = &server.received_requests().await.unwrap()[0];
|
||||
let request_body = request.body_json::<serde_json::Value>().unwrap();
|
||||
let expected_input = json!([
|
||||
{
|
||||
"type": "message",
|
||||
@@ -366,9 +372,18 @@ async fn includes_base_instructions_override_in_request() {
|
||||
skip_if_no_network!();
|
||||
// Mock server
|
||||
let server = MockServer::start().await;
|
||||
let resp_mock =
|
||||
responses::mount_sse_once_match(&server, path("/v1/responses"), sse_completed("resp1"))
|
||||
.await;
|
||||
|
||||
// First request – must NOT include `previous_response_id`.
|
||||
let first = ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "text/event-stream")
|
||||
.set_body_raw(sse_completed("resp1"), "text/event-stream");
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/responses"))
|
||||
.respond_with(first)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let model_provider = ModelProviderInfo {
|
||||
base_url: Some(format!("{}/v1", server.uri())),
|
||||
@@ -399,8 +414,8 @@ async fn includes_base_instructions_override_in_request() {
|
||||
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
|
||||
let request = resp_mock.single_request();
|
||||
let request_body = request.body_json();
|
||||
let request = &server.received_requests().await.unwrap()[0];
|
||||
let request_body = request.body_json::<serde_json::Value>().unwrap();
|
||||
|
||||
assert!(
|
||||
request_body["instructions"]
|
||||
@@ -555,9 +570,16 @@ async fn includes_user_instructions_message_in_request() {
|
||||
skip_if_no_network!();
|
||||
let server = MockServer::start().await;
|
||||
|
||||
let resp_mock =
|
||||
responses::mount_sse_once_match(&server, path("/v1/responses"), sse_completed("resp1"))
|
||||
.await;
|
||||
let first = ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "text/event-stream")
|
||||
.set_body_raw(sse_completed("resp1"), "text/event-stream");
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/responses"))
|
||||
.respond_with(first)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let model_provider = ModelProviderInfo {
|
||||
base_url: Some(format!("{}/v1", server.uri())),
|
||||
@@ -588,8 +610,8 @@ async fn includes_user_instructions_message_in_request() {
|
||||
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
|
||||
let request = resp_mock.single_request();
|
||||
let request_body = request.body_json();
|
||||
let request = &server.received_requests().await.unwrap()[0];
|
||||
let request_body = request.body_json::<serde_json::Value>().unwrap();
|
||||
|
||||
assert!(
|
||||
!request_body["instructions"]
|
||||
|
||||
@@ -1,460 +0,0 @@
|
||||
#![cfg(not(target_os = "windows"))]
|
||||
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::InputItem;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_function_call;
|
||||
use core_test_support::responses::ev_response_created;
|
||||
use core_test_support::responses::sse;
|
||||
use core_test_support::responses::start_mock_server;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::test_codex::TestCodex;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use wiremock::matchers::any;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "disabled until we enable list_dir tool"]
|
||||
async fn list_dir_tool_returns_entries() -> anyhow::Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
|
||||
let TestCodex {
|
||||
codex,
|
||||
cwd,
|
||||
session_configured,
|
||||
..
|
||||
} = test_codex().build(&server).await?;
|
||||
|
||||
let dir_path = cwd.path().join("sample_dir");
|
||||
std::fs::create_dir(&dir_path)?;
|
||||
std::fs::write(dir_path.join("alpha.txt"), "first file")?;
|
||||
std::fs::create_dir(dir_path.join("nested"))?;
|
||||
let dir_path = dir_path.to_string_lossy().to_string();
|
||||
|
||||
let call_id = "list-dir-call";
|
||||
let arguments = serde_json::json!({
|
||||
"dir_path": dir_path,
|
||||
"offset": 1,
|
||||
"limit": 2,
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let first_response = sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_function_call(call_id, "list_dir", &arguments),
|
||||
ev_completed("resp-1"),
|
||||
]);
|
||||
responses::mount_sse_once_match(&server, any(), first_response).await;
|
||||
|
||||
let second_response = sse(vec![
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-2"),
|
||||
]);
|
||||
responses::mount_sse_once_match(&server, any(), second_response).await;
|
||||
|
||||
let session_model = session_configured.model.clone();
|
||||
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![InputItem::Text {
|
||||
text: "list directory contents".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
})
|
||||
.await?;
|
||||
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
|
||||
let requests = server.received_requests().await.expect("recorded requests");
|
||||
let request_bodies = requests
|
||||
.iter()
|
||||
.map(|req| req.body_json::<Value>().unwrap())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(
|
||||
!request_bodies.is_empty(),
|
||||
"expected at least one request body"
|
||||
);
|
||||
|
||||
let tool_output_item = request_bodies
|
||||
.iter()
|
||||
.find_map(|body| {
|
||||
body.get("input")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|items| {
|
||||
items.iter().find(|item| {
|
||||
item.get("type").and_then(Value::as_str) == Some("function_call_output")
|
||||
})
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
panic!("function_call_output item not found in requests: {request_bodies:#?}")
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
tool_output_item.get("call_id").and_then(Value::as_str),
|
||||
Some(call_id)
|
||||
);
|
||||
|
||||
let output_text = tool_output_item
|
||||
.get("output")
|
||||
.and_then(|value| match value {
|
||||
Value::String(text) => Some(text.as_str()),
|
||||
Value::Object(obj) => obj.get("content").and_then(Value::as_str),
|
||||
_ => None,
|
||||
})
|
||||
.expect("output text present");
|
||||
assert_eq!(output_text, "E1: [file] alpha.txt\nE2: [dir] nested");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "disabled until we enable list_dir tool"]
|
||||
async fn list_dir_tool_depth_one_omits_children() -> anyhow::Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
|
||||
let TestCodex {
|
||||
codex,
|
||||
cwd,
|
||||
session_configured,
|
||||
..
|
||||
} = test_codex().build(&server).await?;
|
||||
|
||||
let dir_path = cwd.path().join("depth_one");
|
||||
std::fs::create_dir(&dir_path)?;
|
||||
std::fs::write(dir_path.join("alpha.txt"), "alpha")?;
|
||||
std::fs::create_dir(dir_path.join("nested"))?;
|
||||
std::fs::write(dir_path.join("nested").join("beta.txt"), "beta")?;
|
||||
let dir_path = dir_path.to_string_lossy().to_string();
|
||||
|
||||
let call_id = "list-dir-depth1";
|
||||
let arguments = serde_json::json!({
|
||||
"dir_path": dir_path,
|
||||
"offset": 1,
|
||||
"limit": 10,
|
||||
"depth": 1,
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let first_response = sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_function_call(call_id, "list_dir", &arguments),
|
||||
ev_completed("resp-1"),
|
||||
]);
|
||||
responses::mount_sse_once_match(&server, any(), first_response).await;
|
||||
|
||||
let second_response = sse(vec![
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-2"),
|
||||
]);
|
||||
responses::mount_sse_once_match(&server, any(), second_response).await;
|
||||
|
||||
let session_model = session_configured.model.clone();
|
||||
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![InputItem::Text {
|
||||
text: "list directory contents depth one".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
})
|
||||
.await?;
|
||||
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
|
||||
let requests = server.received_requests().await.expect("recorded requests");
|
||||
let request_bodies = requests
|
||||
.iter()
|
||||
.map(|req| req.body_json::<Value>().unwrap())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(
|
||||
!request_bodies.is_empty(),
|
||||
"expected at least one request body"
|
||||
);
|
||||
|
||||
let tool_output_item = request_bodies
|
||||
.iter()
|
||||
.find_map(|body| {
|
||||
body.get("input")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|items| {
|
||||
items.iter().find(|item| {
|
||||
item.get("type").and_then(Value::as_str) == Some("function_call_output")
|
||||
})
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
panic!("function_call_output item not found in requests: {request_bodies:#?}")
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
tool_output_item.get("call_id").and_then(Value::as_str),
|
||||
Some(call_id)
|
||||
);
|
||||
|
||||
let output_text = tool_output_item
|
||||
.get("output")
|
||||
.and_then(|value| match value {
|
||||
Value::String(text) => Some(text.as_str()),
|
||||
Value::Object(obj) => obj.get("content").and_then(Value::as_str),
|
||||
_ => None,
|
||||
})
|
||||
.expect("output text present");
|
||||
assert_eq!(output_text, "E1: [file] alpha.txt\nE2: [dir] nested");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "disabled until we enable list_dir tool"]
|
||||
async fn list_dir_tool_depth_two_includes_children_only() -> anyhow::Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
|
||||
let TestCodex {
|
||||
codex,
|
||||
cwd,
|
||||
session_configured,
|
||||
..
|
||||
} = test_codex().build(&server).await?;
|
||||
|
||||
let dir_path = cwd.path().join("depth_two");
|
||||
std::fs::create_dir(&dir_path)?;
|
||||
std::fs::write(dir_path.join("alpha.txt"), "alpha")?;
|
||||
let nested = dir_path.join("nested");
|
||||
std::fs::create_dir(&nested)?;
|
||||
std::fs::write(nested.join("beta.txt"), "beta")?;
|
||||
let deeper = nested.join("grand");
|
||||
std::fs::create_dir(&deeper)?;
|
||||
std::fs::write(deeper.join("gamma.txt"), "gamma")?;
|
||||
let dir_path_string = dir_path.to_string_lossy().to_string();
|
||||
|
||||
let call_id = "list-dir-depth2";
|
||||
let arguments = serde_json::json!({
|
||||
"dir_path": dir_path_string,
|
||||
"offset": 1,
|
||||
"limit": 10,
|
||||
"depth": 2,
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let first_response = sse(vec![
|
||||
serde_json::json!({
|
||||
"type": "response.created",
|
||||
"response": {"id": "resp-1"}
|
||||
}),
|
||||
ev_function_call(call_id, "list_dir", &arguments),
|
||||
ev_completed("resp-1"),
|
||||
]);
|
||||
responses::mount_sse_once_match(&server, any(), first_response).await;
|
||||
|
||||
let second_response = sse(vec![
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-2"),
|
||||
]);
|
||||
responses::mount_sse_once_match(&server, any(), second_response).await;
|
||||
|
||||
let session_model = session_configured.model.clone();
|
||||
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![InputItem::Text {
|
||||
text: "list directory contents depth two".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
})
|
||||
.await?;
|
||||
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
|
||||
let requests = server.received_requests().await.expect("recorded requests");
|
||||
let request_bodies = requests
|
||||
.iter()
|
||||
.map(|req| req.body_json::<Value>().unwrap())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(
|
||||
!request_bodies.is_empty(),
|
||||
"expected at least one request body"
|
||||
);
|
||||
|
||||
let tool_output_item = request_bodies
|
||||
.iter()
|
||||
.find_map(|body| {
|
||||
body.get("input")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|items| {
|
||||
items.iter().find(|item| {
|
||||
item.get("type").and_then(Value::as_str) == Some("function_call_output")
|
||||
})
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
panic!("function_call_output item not found in requests: {request_bodies:#?}")
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
tool_output_item.get("call_id").and_then(Value::as_str),
|
||||
Some(call_id)
|
||||
);
|
||||
|
||||
let output_text = tool_output_item
|
||||
.get("output")
|
||||
.and_then(|value| match value {
|
||||
Value::String(text) => Some(text.as_str()),
|
||||
Value::Object(obj) => obj.get("content").and_then(Value::as_str),
|
||||
_ => None,
|
||||
})
|
||||
.expect("output text present");
|
||||
assert_eq!(
|
||||
output_text,
|
||||
"E1: [file] alpha.txt\nE2: [dir] nested\nE3: [file] nested/beta.txt\nE4: [dir] nested/grand"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "disabled until we enable list_dir tool"]
|
||||
async fn list_dir_tool_depth_three_includes_grandchildren() -> anyhow::Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
|
||||
let TestCodex {
|
||||
codex,
|
||||
cwd,
|
||||
session_configured,
|
||||
..
|
||||
} = test_codex().build(&server).await?;
|
||||
|
||||
let dir_path = cwd.path().join("depth_three");
|
||||
std::fs::create_dir(&dir_path)?;
|
||||
std::fs::write(dir_path.join("alpha.txt"), "alpha")?;
|
||||
let nested = dir_path.join("nested");
|
||||
std::fs::create_dir(&nested)?;
|
||||
std::fs::write(nested.join("beta.txt"), "beta")?;
|
||||
let deeper = nested.join("grand");
|
||||
std::fs::create_dir(&deeper)?;
|
||||
std::fs::write(deeper.join("gamma.txt"), "gamma")?;
|
||||
let dir_path_string = dir_path.to_string_lossy().to_string();
|
||||
|
||||
let call_id = "list-dir-depth3";
|
||||
let arguments = serde_json::json!({
|
||||
"dir_path": dir_path_string,
|
||||
"offset": 1,
|
||||
"limit": 10,
|
||||
"depth": 3,
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let first_response = sse(vec![
|
||||
serde_json::json!({
|
||||
"type": "response.created",
|
||||
"response": {"id": "resp-1"}
|
||||
}),
|
||||
ev_function_call(call_id, "list_dir", &arguments),
|
||||
ev_completed("resp-1"),
|
||||
]);
|
||||
responses::mount_sse_once_match(&server, any(), first_response).await;
|
||||
|
||||
let second_response = sse(vec![
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-2"),
|
||||
]);
|
||||
responses::mount_sse_once_match(&server, any(), second_response).await;
|
||||
|
||||
let session_model = session_configured.model.clone();
|
||||
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![InputItem::Text {
|
||||
text: "list directory contents depth three".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
})
|
||||
.await?;
|
||||
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
|
||||
let requests = server.received_requests().await.expect("recorded requests");
|
||||
let request_bodies = requests
|
||||
.iter()
|
||||
.map(|req| req.body_json::<Value>().unwrap())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(
|
||||
!request_bodies.is_empty(),
|
||||
"expected at least one request body"
|
||||
);
|
||||
|
||||
let tool_output_item = request_bodies
|
||||
.iter()
|
||||
.find_map(|body| {
|
||||
body.get("input")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|items| {
|
||||
items.iter().find(|item| {
|
||||
item.get("type").and_then(Value::as_str) == Some("function_call_output")
|
||||
})
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
panic!("function_call_output item not found in requests: {request_bodies:#?}")
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
tool_output_item.get("call_id").and_then(Value::as_str),
|
||||
Some(call_id)
|
||||
);
|
||||
|
||||
let output_text = tool_output_item
|
||||
.get("output")
|
||||
.and_then(|value| match value {
|
||||
Value::String(text) => Some(text.as_str()),
|
||||
Value::Object(obj) => obj.get("content").and_then(Value::as_str),
|
||||
_ => None,
|
||||
})
|
||||
.expect("output text present");
|
||||
assert_eq!(
|
||||
output_text,
|
||||
"E1: [file] alpha.txt\nE2: [dir] nested\nE3: [file] nested/beta.txt\nE4: [dir] nested/grand\nE5: [file] nested/grand/gamma.txt"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -10,7 +10,6 @@ mod exec;
|
||||
mod exec_stream_events;
|
||||
mod fork_conversation;
|
||||
mod json_result;
|
||||
mod list_dir;
|
||||
mod live_cli;
|
||||
mod model_overrides;
|
||||
mod model_tools;
|
||||
|
||||
@@ -10,11 +10,14 @@ use codex_core::protocol::InputItem;
|
||||
use codex_core::protocol::Op;
|
||||
use core_test_support::load_default_config_for_test;
|
||||
use core_test_support::load_sse_fixture_with_id;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::wait_for_event;
|
||||
use tempfile::TempDir;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
fn sse_completed(id: &str) -> String {
|
||||
load_sse_fixture_with_id("tests/fixtures/completed_template.json", id)
|
||||
@@ -41,7 +44,16 @@ async fn collect_tool_identifiers_for_model(model: &str) -> Vec<String> {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
let sse = sse_completed(model);
|
||||
let resp_mock = responses::mount_sse_once_match(&server, wiremock::matchers::any(), sse).await;
|
||||
let template = ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "text/event-stream")
|
||||
.set_body_raw(sse, "text/event-stream");
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/responses"))
|
||||
.respond_with(template)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let model_provider = ModelProviderInfo {
|
||||
base_url: Some(format!("{}/v1", server.uri())),
|
||||
@@ -81,7 +93,13 @@ async fn collect_tool_identifiers_for_model(model: &str) -> Vec<String> {
|
||||
.unwrap();
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
|
||||
let body = resp_mock.single_request().body_json();
|
||||
let requests = server.received_requests().await.unwrap();
|
||||
assert_eq!(
|
||||
requests.len(),
|
||||
1,
|
||||
"expected a single request for model {model}"
|
||||
);
|
||||
let body = requests[0].body_json::<serde_json::Value>().unwrap();
|
||||
tool_identifiers(&body)
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ async fn read_file_tool_returns_requested_lines() -> anyhow::Result<()> {
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-2"),
|
||||
]);
|
||||
let second_mock = responses::mount_sse_once_match(&server, any(), second_response).await;
|
||||
responses::mount_sse_once_match(&server, any(), second_response).await;
|
||||
|
||||
let session_model = session_configured.model.clone();
|
||||
|
||||
@@ -79,12 +79,36 @@ async fn read_file_tool_returns_requested_lines() -> anyhow::Result<()> {
|
||||
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
|
||||
let req = second_mock.single_request();
|
||||
let tool_output_item = req.function_call_output(call_id);
|
||||
let requests = server.received_requests().await.expect("recorded requests");
|
||||
let request_bodies = requests
|
||||
.iter()
|
||||
.map(|req| req.body_json::<Value>().unwrap())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(
|
||||
!request_bodies.is_empty(),
|
||||
"expected at least one request body"
|
||||
);
|
||||
|
||||
let tool_output_item = request_bodies
|
||||
.iter()
|
||||
.find_map(|body| {
|
||||
body.get("input")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|items| {
|
||||
items.iter().find(|item| {
|
||||
item.get("type").and_then(Value::as_str) == Some("function_call_output")
|
||||
})
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
panic!("function_call_output item not found in requests: {request_bodies:#?}")
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
tool_output_item.get("call_id").and_then(Value::as_str),
|
||||
Some(call_id)
|
||||
);
|
||||
|
||||
let output_text = tool_output_item
|
||||
.get("output")
|
||||
.and_then(|value| match value {
|
||||
|
||||
@@ -445,7 +445,7 @@ async fn review_input_isolated_from_parent_history() {
|
||||
.await;
|
||||
let _complete = wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
|
||||
// Assert the request `input` contains the environment context followed by the user review prompt.
|
||||
// Assert the request `input` contains the environment context followed by the review prompt.
|
||||
let request = &server.received_requests().await.unwrap()[0];
|
||||
let body = request.body_json::<serde_json::Value>().unwrap();
|
||||
let input = body["input"].as_array().expect("input array");
|
||||
@@ -473,14 +473,9 @@ async fn review_input_isolated_from_parent_history() {
|
||||
assert_eq!(review_msg["role"].as_str().unwrap(), "user");
|
||||
assert_eq!(
|
||||
review_msg["content"][0]["text"].as_str().unwrap(),
|
||||
review_prompt,
|
||||
"user message should only contain the raw review prompt"
|
||||
format!("{REVIEW_PROMPT}\n\n---\n\nNow, here's your task: Please review only this",)
|
||||
);
|
||||
|
||||
// Ensure the REVIEW_PROMPT rubric is sent via instructions.
|
||||
let instructions = body["instructions"].as_str().expect("instructions string");
|
||||
assert_eq!(instructions, REVIEW_PROMPT);
|
||||
|
||||
// Also verify that a user interruption note was recorded in the rollout.
|
||||
codex.submit(Op::GetPath).await.unwrap();
|
||||
let history_event =
|
||||
|
||||
@@ -27,6 +27,16 @@ use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use wiremock::matchers::any;
|
||||
|
||||
fn function_call_output(body: &Value) -> Option<&Value> {
|
||||
body.get("input")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|items| {
|
||||
items.iter().find(|item| {
|
||||
item.get("type").and_then(Value::as_str) == Some("function_call_output")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_output_text(item: &Value) -> Option<&str> {
|
||||
item.get("output").and_then(|value| match value {
|
||||
Value::String(text) => Some(text.as_str()),
|
||||
@@ -35,6 +45,12 @@ fn extract_output_text(item: &Value) -> Option<&str> {
|
||||
})
|
||||
}
|
||||
|
||||
fn find_request_with_function_call_output(requests: &[Value]) -> Option<&Value> {
|
||||
requests
|
||||
.iter()
|
||||
.find(|body| function_call_output(body).is_some())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
@@ -65,7 +81,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()>
|
||||
ev_assistant_message("msg-1", "all done"),
|
||||
ev_completed("resp-2"),
|
||||
]);
|
||||
let second_mock = responses::mount_sse_once_match(&server, any(), second_response).await;
|
||||
responses::mount_sse_once_match(&server, any(), second_response).await;
|
||||
|
||||
let session_model = session_configured.model.clone();
|
||||
|
||||
@@ -86,9 +102,18 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()>
|
||||
|
||||
wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await;
|
||||
|
||||
let req = second_mock.single_request();
|
||||
let output_item = req.function_call_output(call_id);
|
||||
let output_text = extract_output_text(&output_item).expect("output text present");
|
||||
let requests = server.received_requests().await.expect("recorded requests");
|
||||
assert!(!requests.is_empty(), "expected at least one POST request");
|
||||
|
||||
let request_bodies = requests
|
||||
.iter()
|
||||
.map(|req| req.body_json::<Value>().expect("request json"))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let body_with_tool_output = find_request_with_function_call_output(&request_bodies)
|
||||
.expect("function_call_output item not found in requests");
|
||||
let output_item = function_call_output(body_with_tool_output).expect("tool output item");
|
||||
let output_text = extract_output_text(output_item).expect("output text present");
|
||||
let exec_output: Value = serde_json::from_str(output_text)?;
|
||||
assert_eq!(exec_output["metadata"]["exit_code"], 0);
|
||||
let stdout = exec_output["output"].as_str().expect("stdout field");
|
||||
@@ -134,7 +159,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> {
|
||||
ev_assistant_message("msg-1", "plan acknowledged"),
|
||||
ev_completed("resp-2"),
|
||||
]);
|
||||
let second_mock = responses::mount_sse_once_match(&server, any(), second_response).await;
|
||||
responses::mount_sse_once_match(&server, any(), second_response).await;
|
||||
|
||||
let session_model = session_configured.model.clone();
|
||||
|
||||
@@ -172,13 +197,22 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> {
|
||||
|
||||
assert!(saw_plan_update, "expected PlanUpdate event");
|
||||
|
||||
let req = second_mock.single_request();
|
||||
let output_item = req.function_call_output(call_id);
|
||||
let requests = server.received_requests().await.expect("recorded requests");
|
||||
assert!(!requests.is_empty(), "expected at least one POST request");
|
||||
|
||||
let request_bodies = requests
|
||||
.iter()
|
||||
.map(|req| req.body_json::<Value>().expect("request json"))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let body_with_tool_output = find_request_with_function_call_output(&request_bodies)
|
||||
.expect("function_call_output item not found in requests");
|
||||
let output_item = function_call_output(body_with_tool_output).expect("tool output item");
|
||||
assert_eq!(
|
||||
output_item.get("call_id").and_then(Value::as_str),
|
||||
Some(call_id)
|
||||
);
|
||||
let output_text = extract_output_text(&output_item).expect("output text present");
|
||||
let output_text = extract_output_text(output_item).expect("output text present");
|
||||
assert_eq!(output_text, "Plan updated");
|
||||
|
||||
Ok(())
|
||||
@@ -217,7 +251,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> {
|
||||
ev_assistant_message("msg-1", "malformed plan payload"),
|
||||
ev_completed("resp-2"),
|
||||
]);
|
||||
let second_mock = responses::mount_sse_once_match(&server, any(), second_response).await;
|
||||
responses::mount_sse_once_match(&server, any(), second_response).await;
|
||||
|
||||
let session_model = session_configured.model.clone();
|
||||
|
||||
@@ -252,13 +286,22 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> {
|
||||
"did not expect PlanUpdate event for malformed payload"
|
||||
);
|
||||
|
||||
let req = second_mock.single_request();
|
||||
let output_item = req.function_call_output(call_id);
|
||||
let requests = server.received_requests().await.expect("recorded requests");
|
||||
assert!(!requests.is_empty(), "expected at least one POST request");
|
||||
|
||||
let request_bodies = requests
|
||||
.iter()
|
||||
.map(|req| req.body_json::<Value>().expect("request json"))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let body_with_tool_output = find_request_with_function_call_output(&request_bodies)
|
||||
.expect("function_call_output item not found in requests");
|
||||
let output_item = function_call_output(body_with_tool_output).expect("tool output item");
|
||||
assert_eq!(
|
||||
output_item.get("call_id").and_then(Value::as_str),
|
||||
Some(call_id)
|
||||
);
|
||||
let output_text = extract_output_text(&output_item).expect("output text present");
|
||||
let output_text = extract_output_text(output_item).expect("output text present");
|
||||
assert!(
|
||||
output_text.contains("failed to parse function arguments"),
|
||||
"expected parse error message in output text, got {output_text:?}"
|
||||
@@ -311,7 +354,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<()
|
||||
ev_assistant_message("msg-1", "patch complete"),
|
||||
ev_completed("resp-2"),
|
||||
]);
|
||||
let second_mock = responses::mount_sse_once_match(&server, any(), second_response).await;
|
||||
responses::mount_sse_once_match(&server, any(), second_response).await;
|
||||
|
||||
let session_model = session_configured.model.clone();
|
||||
|
||||
@@ -352,13 +395,22 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<()
|
||||
let patch_end_success =
|
||||
patch_end_success.expect("expected PatchApplyEnd event to capture success flag");
|
||||
|
||||
let req = second_mock.single_request();
|
||||
let output_item = req.function_call_output(call_id);
|
||||
let requests = server.received_requests().await.expect("recorded requests");
|
||||
assert!(!requests.is_empty(), "expected at least one POST request");
|
||||
|
||||
let request_bodies = requests
|
||||
.iter()
|
||||
.map(|req| req.body_json::<Value>().expect("request json"))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let body_with_tool_output = find_request_with_function_call_output(&request_bodies)
|
||||
.expect("function_call_output item not found in requests");
|
||||
let output_item = function_call_output(body_with_tool_output).expect("tool output item");
|
||||
assert_eq!(
|
||||
output_item.get("call_id").and_then(Value::as_str),
|
||||
Some(call_id)
|
||||
);
|
||||
let output_text = extract_output_text(&output_item).expect("output text present");
|
||||
let output_text = extract_output_text(output_item).expect("output text present");
|
||||
|
||||
if let Ok(exec_output) = serde_json::from_str::<Value>(output_text) {
|
||||
let exit_code = exec_output["metadata"]["exit_code"]
|
||||
@@ -428,7 +480,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> {
|
||||
ev_assistant_message("msg-1", "failed"),
|
||||
ev_completed("resp-2"),
|
||||
]);
|
||||
let second_mock = responses::mount_sse_once_match(&server, any(), second_response).await;
|
||||
responses::mount_sse_once_match(&server, any(), second_response).await;
|
||||
|
||||
let session_model = session_configured.model.clone();
|
||||
|
||||
@@ -449,13 +501,22 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> {
|
||||
|
||||
wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await;
|
||||
|
||||
let req = second_mock.single_request();
|
||||
let output_item = req.function_call_output(call_id);
|
||||
let requests = server.received_requests().await.expect("recorded requests");
|
||||
assert!(!requests.is_empty(), "expected at least one POST request");
|
||||
|
||||
let request_bodies = requests
|
||||
.iter()
|
||||
.map(|req| req.body_json::<Value>().expect("request json"))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let body_with_tool_output = find_request_with_function_call_output(&request_bodies)
|
||||
.expect("function_call_output item not found in requests");
|
||||
let output_item = function_call_output(body_with_tool_output).expect("tool output item");
|
||||
assert_eq!(
|
||||
output_item.get("call_id").and_then(Value::as_str),
|
||||
Some(call_id)
|
||||
);
|
||||
let output_text = extract_output_text(&output_item).expect("output text present");
|
||||
let output_text = extract_output_text(output_item).expect("output text present");
|
||||
|
||||
assert!(
|
||||
output_text.contains("apply_patch verification failed"),
|
||||
|
||||
@@ -83,16 +83,6 @@ async fn read_file_tools_run_in_parallel() -> anyhow::Result<()> {
|
||||
let server = start_mock_server().await;
|
||||
let test = build_codex_with_test_tool(&server).await?;
|
||||
|
||||
let warmup_args = json!({
|
||||
"sleep_after_ms": 10,
|
||||
"barrier": {
|
||||
"id": "parallel-test-sync-warmup",
|
||||
"participants": 2,
|
||||
"timeout_ms": 1_000,
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let parallel_args = json!({
|
||||
"sleep_after_ms": 300,
|
||||
"barrier": {
|
||||
@@ -103,17 +93,6 @@ async fn read_file_tools_run_in_parallel() -> anyhow::Result<()> {
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let warmup_first = sse(vec![
|
||||
json!({"type": "response.created", "response": {"id": "resp-warm-1"}}),
|
||||
ev_function_call("warm-call-1", "test_sync_tool", &warmup_args),
|
||||
ev_function_call("warm-call-2", "test_sync_tool", &warmup_args),
|
||||
ev_completed("resp-warm-1"),
|
||||
]);
|
||||
let warmup_second = sse(vec![
|
||||
ev_assistant_message("warm-msg-1", "warmup complete"),
|
||||
ev_completed("resp-warm-2"),
|
||||
]);
|
||||
|
||||
let first_response = sse(vec![
|
||||
json!({"type": "response.created", "response": {"id": "resp-1"}}),
|
||||
ev_function_call("call-1", "test_sync_tool", ¶llel_args),
|
||||
@@ -124,13 +103,7 @@ async fn read_file_tools_run_in_parallel() -> anyhow::Result<()> {
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-2"),
|
||||
]);
|
||||
mount_sse_sequence(
|
||||
&server,
|
||||
vec![warmup_first, warmup_second, first_response, second_response],
|
||||
)
|
||||
.await;
|
||||
|
||||
run_turn(&test, "warm up parallel tool").await?;
|
||||
mount_sse_sequence(&server, vec![first_response, second_response]).await;
|
||||
|
||||
let duration = run_turn_and_measure(&test, "exercise sync tool").await?;
|
||||
assert_parallel_duration(duration);
|
||||
|
||||
@@ -15,7 +15,6 @@ use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_custom_tool_call;
|
||||
use core_test_support::responses::ev_function_call;
|
||||
use core_test_support::responses::ev_response_created;
|
||||
use core_test_support::responses::mount_sse_once;
|
||||
use core_test_support::responses::mount_sse_sequence;
|
||||
use core_test_support::responses::sse;
|
||||
use core_test_support::responses::start_mock_server;
|
||||
@@ -26,6 +25,7 @@ use core_test_support::wait_for_event;
|
||||
use regex_lite::Regex;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use wiremock::Request;
|
||||
|
||||
async fn submit_turn(
|
||||
test: &TestCodex,
|
||||
@@ -58,6 +58,27 @@ async fn submit_turn(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn request_bodies(requests: &[Request]) -> Result<Vec<Value>> {
|
||||
requests
|
||||
.iter()
|
||||
.map(|req| Ok(serde_json::from_slice::<Value>(&req.body)?))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn collect_output_items<'a>(bodies: &'a [Value], ty: &str) -> Vec<&'a Value> {
|
||||
let mut out = Vec::new();
|
||||
for body in bodies {
|
||||
if let Some(items) = body.get("input").and_then(Value::as_array) {
|
||||
for item in items {
|
||||
if item.get("type").and_then(Value::as_str) == Some(ty) {
|
||||
out.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn tool_names(body: &Value) -> Vec<String> {
|
||||
body.get("tools")
|
||||
.and_then(Value::as_array)
|
||||
@@ -86,23 +107,18 @@ async fn custom_tool_unknown_returns_custom_output_error() -> Result<()> {
|
||||
let call_id = "custom-unsupported";
|
||||
let tool_name = "unsupported_tool";
|
||||
|
||||
mount_sse_once(
|
||||
&server,
|
||||
let responses = vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_custom_tool_call(call_id, tool_name, "\"payload\""),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let mock = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
];
|
||||
mount_sse_sequence(&server, responses).await;
|
||||
|
||||
submit_turn(
|
||||
&test,
|
||||
@@ -112,7 +128,13 @@ async fn custom_tool_unknown_returns_custom_output_error() -> Result<()> {
|
||||
)
|
||||
.await?;
|
||||
|
||||
let item = mock.single_request().custom_tool_call_output(call_id);
|
||||
let requests = server.received_requests().await.expect("recorded requests");
|
||||
let bodies = request_bodies(&requests)?;
|
||||
let custom_items = collect_output_items(&bodies, "custom_tool_call_output");
|
||||
assert_eq!(custom_items.len(), 1, "expected single custom tool output");
|
||||
let item = custom_items[0];
|
||||
assert_eq!(item.get("call_id").and_then(Value::as_str), Some(call_id));
|
||||
|
||||
let output = item
|
||||
.get("output")
|
||||
.and_then(Value::as_str)
|
||||
@@ -148,8 +170,7 @@ async fn shell_escalated_permissions_rejected_then_ok() -> Result<()> {
|
||||
"timeout_ms": 1_000,
|
||||
});
|
||||
|
||||
mount_sse_once(
|
||||
&server,
|
||||
let responses = vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_function_call(
|
||||
@@ -159,10 +180,6 @@ async fn shell_escalated_permissions_rejected_then_ok() -> Result<()> {
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let second_mock = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-2"),
|
||||
ev_function_call(
|
||||
@@ -172,16 +189,12 @@ async fn shell_escalated_permissions_rejected_then_ok() -> Result<()> {
|
||||
),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let third_mock = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-3"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
];
|
||||
mount_sse_sequence(&server, responses).await;
|
||||
|
||||
submit_turn(
|
||||
&test,
|
||||
@@ -191,23 +204,46 @@ async fn shell_escalated_permissions_rejected_then_ok() -> Result<()> {
|
||||
)
|
||||
.await?;
|
||||
|
||||
let requests = server.received_requests().await.expect("recorded requests");
|
||||
let bodies = request_bodies(&requests)?;
|
||||
let function_outputs = collect_output_items(&bodies, "function_call_output");
|
||||
for item in &function_outputs {
|
||||
let call_id = item
|
||||
.get("call_id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
call_id == call_id_blocked || call_id == call_id_success,
|
||||
"unexpected call id {call_id}"
|
||||
);
|
||||
}
|
||||
|
||||
let policy = AskForApproval::Never;
|
||||
let expected_message = format!(
|
||||
"approval policy is {policy:?}; reject command — you should not ask for escalated permissions if the approval policy is {policy:?}"
|
||||
);
|
||||
|
||||
let blocked_item = second_mock
|
||||
.single_request()
|
||||
.function_call_output(call_id_blocked);
|
||||
assert_eq!(
|
||||
blocked_item.get("output").and_then(Value::as_str),
|
||||
Some(expected_message.as_str()),
|
||||
"unexpected rejection message"
|
||||
let blocked_outputs: Vec<&Value> = function_outputs
|
||||
.iter()
|
||||
.filter(|item| item.get("call_id").and_then(Value::as_str) == Some(call_id_blocked))
|
||||
.copied()
|
||||
.collect();
|
||||
assert!(
|
||||
!blocked_outputs.is_empty(),
|
||||
"expected at least one rejection output for {call_id_blocked}"
|
||||
);
|
||||
for item in blocked_outputs {
|
||||
assert_eq!(
|
||||
item.get("output").and_then(Value::as_str),
|
||||
Some(expected_message.as_str()),
|
||||
"unexpected rejection message"
|
||||
);
|
||||
}
|
||||
|
||||
let success_item = third_mock
|
||||
.single_request()
|
||||
.function_call_output(call_id_success);
|
||||
let success_item = function_outputs
|
||||
.iter()
|
||||
.find(|item| item.get("call_id").and_then(Value::as_str) == Some(call_id_success))
|
||||
.expect("success output present");
|
||||
let output_json: Value = serde_json::from_str(
|
||||
success_item
|
||||
.get("output")
|
||||
@@ -246,23 +282,18 @@ async fn local_shell_missing_ids_maps_to_function_output_error() -> Result<()> {
|
||||
}
|
||||
});
|
||||
|
||||
mount_sse_once(
|
||||
&server,
|
||||
let responses = vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
local_shell_event,
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let second_mock = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
];
|
||||
mount_sse_sequence(&server, responses).await;
|
||||
|
||||
submit_turn(
|
||||
&test,
|
||||
@@ -272,7 +303,15 @@ async fn local_shell_missing_ids_maps_to_function_output_error() -> Result<()> {
|
||||
)
|
||||
.await?;
|
||||
|
||||
let item = second_mock.single_request().function_call_output("");
|
||||
let requests = server.received_requests().await.expect("recorded requests");
|
||||
let bodies = request_bodies(&requests)?;
|
||||
let function_outputs = collect_output_items(&bodies, "function_call_output");
|
||||
assert_eq!(
|
||||
function_outputs.len(),
|
||||
1,
|
||||
"expected a single function output"
|
||||
);
|
||||
let item = function_outputs[0];
|
||||
assert_eq!(item.get("call_id").and_then(Value::as_str), Some(""));
|
||||
assert_eq!(
|
||||
item.get("output").and_then(Value::as_str),
|
||||
@@ -290,7 +329,7 @@ async fn collect_tools(use_unified_exec: bool) -> Result<Vec<String>> {
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-1"),
|
||||
])];
|
||||
let mock = mount_sse_sequence(&server, responses).await;
|
||||
mount_sse_sequence(&server, responses).await;
|
||||
|
||||
let mut builder = test_codex().with_config(move |config| {
|
||||
config.use_experimental_unified_exec_tool = use_unified_exec;
|
||||
@@ -305,8 +344,15 @@ async fn collect_tools(use_unified_exec: bool) -> Result<Vec<String>> {
|
||||
)
|
||||
.await?;
|
||||
|
||||
let first_body = mock.single_request().body_json();
|
||||
Ok(tool_names(&first_body))
|
||||
let requests = server.received_requests().await.expect("recorded requests");
|
||||
assert_eq!(
|
||||
requests.len(),
|
||||
1,
|
||||
"expected a single request for tools collection"
|
||||
);
|
||||
let bodies = request_bodies(&requests)?;
|
||||
let first_body = bodies.first().expect("request body present");
|
||||
Ok(tool_names(first_body))
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
@@ -346,23 +392,18 @@ async fn shell_timeout_includes_timeout_prefix_and_metadata() -> Result<()> {
|
||||
"timeout_ms": timeout_ms,
|
||||
});
|
||||
|
||||
mount_sse_once(
|
||||
&server,
|
||||
let responses = vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_function_call(call_id, "shell", &serde_json::to_string(&args)?),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let second_mock = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
];
|
||||
mount_sse_sequence(&server, responses).await;
|
||||
|
||||
submit_turn(
|
||||
&test,
|
||||
@@ -372,7 +413,13 @@ async fn shell_timeout_includes_timeout_prefix_and_metadata() -> Result<()> {
|
||||
)
|
||||
.await?;
|
||||
|
||||
let timeout_item = second_mock.single_request().function_call_output(call_id);
|
||||
let requests = server.received_requests().await.expect("recorded requests");
|
||||
let bodies = request_bodies(&requests)?;
|
||||
let function_outputs = collect_output_items(&bodies, "function_call_output");
|
||||
let timeout_item = function_outputs
|
||||
.iter()
|
||||
.find(|item| item.get("call_id").and_then(Value::as_str) == Some(call_id))
|
||||
.expect("timeout output present");
|
||||
|
||||
let output_str = timeout_item
|
||||
.get("output")
|
||||
@@ -390,9 +437,19 @@ async fn shell_timeout_includes_timeout_prefix_and_metadata() -> Result<()> {
|
||||
);
|
||||
|
||||
let stdout = output_json["output"].as_str().unwrap_or_default();
|
||||
let timeout_pattern = r"(?s)^Total output lines: \d+
|
||||
|
||||
command timed out after (?P<ms>\d+) milliseconds
|
||||
line
|
||||
.*$";
|
||||
let captures = assert_regex_match(timeout_pattern, stdout);
|
||||
let duration_ms = captures
|
||||
.name("ms")
|
||||
.and_then(|m| m.as_str().parse::<u64>().ok())
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
stdout.contains("command timed out"),
|
||||
"timeout output missing `command timed out`: {stdout}"
|
||||
duration_ms >= timeout_ms,
|
||||
"expected duration >= configured timeout, got {duration_ms} (timeout {timeout_ms})"
|
||||
);
|
||||
} else {
|
||||
// Fallback: accept the signal classification path to deflake the test.
|
||||
@@ -421,23 +478,18 @@ async fn shell_sandbox_denied_truncates_error_output() -> Result<()> {
|
||||
"timeout_ms": 1_000,
|
||||
});
|
||||
|
||||
mount_sse_once(
|
||||
&server,
|
||||
let responses = vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_function_call(call_id, "shell", &serde_json::to_string(&args)?),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let second_mock = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
];
|
||||
mount_sse_sequence(&server, responses).await;
|
||||
|
||||
submit_turn(
|
||||
&test,
|
||||
@@ -447,7 +499,13 @@ async fn shell_sandbox_denied_truncates_error_output() -> Result<()> {
|
||||
)
|
||||
.await?;
|
||||
|
||||
let denied_item = second_mock.single_request().function_call_output(call_id);
|
||||
let requests = server.received_requests().await.expect("recorded requests");
|
||||
let bodies = request_bodies(&requests)?;
|
||||
let function_outputs = collect_output_items(&bodies, "function_call_output");
|
||||
let denied_item = function_outputs
|
||||
.iter()
|
||||
.find(|item| item.get("call_id").and_then(Value::as_str) == Some(call_id))
|
||||
.expect("denied output present");
|
||||
|
||||
let output = denied_item
|
||||
.get("output")
|
||||
@@ -500,23 +558,18 @@ async fn shell_spawn_failure_truncates_exec_error() -> Result<()> {
|
||||
"timeout_ms": 1_000,
|
||||
});
|
||||
|
||||
mount_sse_once(
|
||||
&server,
|
||||
let responses = vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_function_call(call_id, "shell", &serde_json::to_string(&args)?),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let second_mock = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
];
|
||||
mount_sse_sequence(&server, responses).await;
|
||||
|
||||
submit_turn(
|
||||
&test,
|
||||
@@ -526,7 +579,13 @@ async fn shell_spawn_failure_truncates_exec_error() -> Result<()> {
|
||||
)
|
||||
.await?;
|
||||
|
||||
let failure_item = second_mock.single_request().function_call_output(call_id);
|
||||
let requests = server.received_requests().await.expect("recorded requests");
|
||||
let bodies = request_bodies(&requests)?;
|
||||
let function_outputs = collect_output_items(&bodies, "function_call_output");
|
||||
let failure_item = function_outputs
|
||||
.iter()
|
||||
.find(|item| item.get("call_id").and_then(Value::as_str) == Some(call_id))
|
||||
.expect("spawn failure output present");
|
||||
|
||||
let output = failure_item
|
||||
.get("output")
|
||||
|
||||
@@ -22,6 +22,16 @@ use core_test_support::wait_for_event;
|
||||
use serde_json::Value;
|
||||
use wiremock::matchers::any;
|
||||
|
||||
fn function_call_output(body: &Value) -> Option<&Value> {
|
||||
body.get("input")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|items| {
|
||||
items.iter().find(|item| {
|
||||
item.get("type").and_then(Value::as_str) == Some("function_call_output")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn find_image_message(body: &Value) -> Option<&Value> {
|
||||
body.get("input")
|
||||
.and_then(Value::as_array)
|
||||
@@ -49,6 +59,12 @@ fn extract_output_text(item: &Value) -> Option<&str> {
|
||||
})
|
||||
}
|
||||
|
||||
fn find_request_with_function_call_output(requests: &[Value]) -> Option<&Value> {
|
||||
requests
|
||||
.iter()
|
||||
.find(|body| function_call_output(body).is_some())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
@@ -84,7 +100,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> {
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-2"),
|
||||
]);
|
||||
let mock = responses::mount_sse_once_match(&server, any(), second_response).await;
|
||||
responses::mount_sse_once_match(&server, any(), second_response).await;
|
||||
|
||||
let session_model = session_configured.model.clone();
|
||||
|
||||
@@ -121,14 +137,25 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> {
|
||||
assert_eq!(tool_event.call_id, call_id);
|
||||
assert_eq!(tool_event.path, abs_path);
|
||||
|
||||
let body = mock.single_request().body_json();
|
||||
let output_item = mock.single_request().function_call_output(call_id);
|
||||
let requests = server.received_requests().await.expect("recorded requests");
|
||||
assert!(
|
||||
requests.len() >= 2,
|
||||
"expected at least two POST requests, got {}",
|
||||
requests.len()
|
||||
);
|
||||
let request_bodies = requests
|
||||
.iter()
|
||||
.map(|req| req.body_json::<Value>().expect("request json"))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let output_text = extract_output_text(&output_item).expect("output text present");
|
||||
let body_with_tool_output = find_request_with_function_call_output(&request_bodies)
|
||||
.expect("function_call_output item not found in requests");
|
||||
let output_item = function_call_output(body_with_tool_output).expect("tool output item");
|
||||
let output_text = extract_output_text(output_item).expect("output text present");
|
||||
assert_eq!(output_text, "attached local image path");
|
||||
|
||||
let image_message =
|
||||
find_image_message(&body).expect("pending input image message not included in request");
|
||||
let image_message = find_image_message(body_with_tool_output)
|
||||
.expect("pending input image message not included in request");
|
||||
let image_url = image_message
|
||||
.get("content")
|
||||
.and_then(Value::as_array)
|
||||
@@ -183,7 +210,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> {
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-2"),
|
||||
]);
|
||||
let mock = responses::mount_sse_once_match(&server, any(), second_response).await;
|
||||
responses::mount_sse_once_match(&server, any(), second_response).await;
|
||||
|
||||
let session_model = session_configured.model.clone();
|
||||
|
||||
@@ -204,14 +231,26 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> {
|
||||
|
||||
wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await;
|
||||
|
||||
let body_with_tool_output = mock.single_request().body_json();
|
||||
let output_item = mock.single_request().function_call_output(call_id);
|
||||
let output_text = extract_output_text(&output_item).expect("output text present");
|
||||
let requests = server.received_requests().await.expect("recorded requests");
|
||||
assert!(
|
||||
requests.len() >= 2,
|
||||
"expected at least two POST requests, got {}",
|
||||
requests.len()
|
||||
);
|
||||
let request_bodies = requests
|
||||
.iter()
|
||||
.map(|req| req.body_json::<Value>().expect("request json"))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let body_with_tool_output = find_request_with_function_call_output(&request_bodies)
|
||||
.expect("function_call_output item not found in requests");
|
||||
let output_item = function_call_output(body_with_tool_output).expect("tool output item");
|
||||
let output_text = extract_output_text(output_item).expect("output text present");
|
||||
let expected_message = format!("image path `{}` is not a file", abs_path.display());
|
||||
assert_eq!(output_text, expected_message);
|
||||
|
||||
assert!(
|
||||
find_image_message(&body_with_tool_output).is_none(),
|
||||
find_image_message(body_with_tool_output).is_none(),
|
||||
"directory path should not produce an input_image message"
|
||||
);
|
||||
|
||||
@@ -248,7 +287,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> {
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-2"),
|
||||
]);
|
||||
let mock = responses::mount_sse_once_match(&server, any(), second_response).await;
|
||||
responses::mount_sse_once_match(&server, any(), second_response).await;
|
||||
|
||||
let session_model = session_configured.model.clone();
|
||||
|
||||
@@ -269,9 +308,21 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> {
|
||||
|
||||
wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await;
|
||||
|
||||
let body_with_tool_output = mock.single_request().body_json();
|
||||
let output_item = mock.single_request().function_call_output(call_id);
|
||||
let output_text = extract_output_text(&output_item).expect("output text present");
|
||||
let requests = server.received_requests().await.expect("recorded requests");
|
||||
assert!(
|
||||
requests.len() >= 2,
|
||||
"expected at least two POST requests, got {}",
|
||||
requests.len()
|
||||
);
|
||||
let request_bodies = requests
|
||||
.iter()
|
||||
.map(|req| req.body_json::<Value>().expect("request json"))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let body_with_tool_output = find_request_with_function_call_output(&request_bodies)
|
||||
.expect("function_call_output item not found in requests");
|
||||
let output_item = function_call_output(body_with_tool_output).expect("tool output item");
|
||||
let output_text = extract_output_text(output_item).expect("output text present");
|
||||
let expected_prefix = format!("unable to locate image at `{}`:", abs_path.display());
|
||||
assert!(
|
||||
output_text.starts_with(&expected_prefix),
|
||||
@@ -279,7 +330,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> {
|
||||
);
|
||||
|
||||
assert!(
|
||||
find_image_message(&body_with_tool_output).is_none(),
|
||||
find_image_message(body_with_tool_output).is_none(),
|
||||
"missing file should not produce an input_image message"
|
||||
);
|
||||
|
||||
|
||||
76
codex-rs/core/tests/windows_appcontainer.rs
Normal file
76
codex-rs/core/tests/windows_appcontainer.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
#![cfg(all(windows, feature = "windows_appcontainer_command_ext"))]
|
||||
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use codex_core::spawn::StdioPolicy;
|
||||
use codex_core::windows_appcontainer::spawn_command_under_windows_appcontainer;
|
||||
use std::collections::HashMap;
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
fn windows_workspace_policy() -> SandboxPolicy {
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![],
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: true,
|
||||
exclude_slash_tmp: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn windows_appcontainer_writes_to_workspace() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let cwd = temp.path().to_path_buf();
|
||||
let policy_cwd = cwd.clone();
|
||||
let mut child = spawn_command_under_windows_appcontainer(
|
||||
vec![
|
||||
"cmd.exe".to_string(),
|
||||
"/C".to_string(),
|
||||
"echo hello>out.txt".to_string(),
|
||||
],
|
||||
cwd.clone(),
|
||||
&windows_workspace_policy(),
|
||||
&policy_cwd,
|
||||
StdioPolicy::RedirectForShellTool,
|
||||
HashMap::new(),
|
||||
)
|
||||
.await
|
||||
.expect("spawn cmd");
|
||||
|
||||
let status = child.wait().await.expect("wait");
|
||||
assert!(status.success(), "cmd.exe failed: {status:?}");
|
||||
|
||||
let contents = tokio::fs::read_to_string(temp.path().join("out.txt"))
|
||||
.await
|
||||
.expect("read redirected output");
|
||||
assert!(contents.to_lowercase().contains("hello"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn windows_appcontainer_sets_env_flags() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let cwd = temp.path().to_path_buf();
|
||||
let policy_cwd = cwd.clone();
|
||||
let mut child = spawn_command_under_windows_appcontainer(
|
||||
vec![
|
||||
"cmd.exe".to_string(),
|
||||
"/C".to_string(),
|
||||
"set CODEX_SANDBOX".to_string(),
|
||||
],
|
||||
cwd,
|
||||
&windows_workspace_policy(),
|
||||
&policy_cwd,
|
||||
StdioPolicy::RedirectForShellTool,
|
||||
HashMap::new(),
|
||||
)
|
||||
.await
|
||||
.expect("spawn cmd");
|
||||
|
||||
let mut stdout = Vec::new();
|
||||
if let Some(mut out) = child.stdout.take() {
|
||||
out.read_to_end(&mut stdout).await.expect("stdout");
|
||||
}
|
||||
let status = child.wait().await.expect("wait");
|
||||
assert!(status.success(), "cmd.exe env probe failed: {status:?}");
|
||||
let stdout_text = String::from_utf8_lossy(&stdout).to_lowercase();
|
||||
assert!(stdout_text.contains("codex_sandbox=windows_appcontainer"));
|
||||
assert!(stdout_text.contains("codex_sandbox_network_disabled=1"));
|
||||
}
|
||||
@@ -77,7 +77,7 @@ pub struct Cli {
|
||||
|
||||
/// Initial instructions for the agent. If not provided as an argument (or
|
||||
/// if `-` is used), instructions are read from stdin.
|
||||
#[arg(value_name = "PROMPT", value_hint = clap::ValueHint::Other)]
|
||||
#[arg(value_name = "PROMPT")]
|
||||
pub prompt: Option<String>,
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ pub struct ResumeArgs {
|
||||
pub last: bool,
|
||||
|
||||
/// Prompt to send after resuming the session. If `-` is used, read from stdin.
|
||||
#[arg(value_name = "PROMPT", value_hint = clap::ValueHint::Other)]
|
||||
#[arg(value_name = "PROMPT")]
|
||||
pub prompt: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ use codex_core::default_client::set_default_originator;
|
||||
use codex_core::find_conversation_path_by_id_str;
|
||||
|
||||
pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()> {
|
||||
if let Err(err) = set_default_originator("codex_exec".to_string()) {
|
||||
if let Err(err) = set_default_originator("codex_exec") {
|
||||
tracing::warn!(?err, "Failed to set codex exec originator override {err:?}");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Aggregates all former standalone integration tests as modules.
|
||||
mod apply_patch;
|
||||
mod auth_env;
|
||||
mod originator;
|
||||
mod output_schema;
|
||||
mod resume;
|
||||
mod sandbox;
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
#![cfg(not(target_os = "windows"))]
|
||||
#![allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
|
||||
use core_test_support::responses;
|
||||
use core_test_support::test_codex_exec::test_codex_exec;
|
||||
use wiremock::matchers::header;
|
||||
|
||||
/// Verify that when the server reports an error, `codex-exec` exits with a
|
||||
/// non-zero status code so automation can detect failures.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn send_codex_exec_originator() -> anyhow::Result<()> {
|
||||
let test = test_codex_exec();
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let body = responses::sse(vec![
|
||||
responses::ev_response_created("response_1"),
|
||||
responses::ev_assistant_message("response_1", "Hello, world!"),
|
||||
responses::ev_completed("response_1"),
|
||||
]);
|
||||
responses::mount_sse_once_match(&server, header("Originator", "codex_exec"), body).await;
|
||||
|
||||
test.cmd_with_server(&server)
|
||||
.arg("--skip-git-repo-check")
|
||||
.arg("tell me something")
|
||||
.assert()
|
||||
.code(0);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn supports_originator_override() -> anyhow::Result<()> {
|
||||
let test = test_codex_exec();
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let body = responses::sse(vec![
|
||||
responses::ev_response_created("response_1"),
|
||||
responses::ev_assistant_message("response_1", "Hello, world!"),
|
||||
responses::ev_completed("response_1"),
|
||||
]);
|
||||
responses::mount_sse_once_match(&server, header("Originator", "codex_exec_override"), body)
|
||||
.await;
|
||||
|
||||
test.cmd_with_server(&server)
|
||||
.env("CODEX_INTERNAL_ORIGINATOR_OVERRIDE", "codex_exec_override")
|
||||
.arg("--skip-git-repo-check")
|
||||
.arg("tell me something")
|
||||
.assert()
|
||||
.code(0);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -28,7 +28,7 @@ async fn exec_includes_output_schema_in_request() -> anyhow::Result<()> {
|
||||
responses::ev_assistant_message("m1", "fixture hello"),
|
||||
responses::ev_completed("resp1"),
|
||||
]);
|
||||
let response_mock = responses::mount_sse_once_match(&server, any(), body).await;
|
||||
responses::mount_sse_once_match(&server, any(), body).await;
|
||||
|
||||
test.cmd_with_server(&server)
|
||||
.arg("--skip-git-repo-check")
|
||||
@@ -43,8 +43,12 @@ async fn exec_includes_output_schema_in_request() -> anyhow::Result<()> {
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let request = response_mock.single_request();
|
||||
let payload: Value = request.body_json();
|
||||
let requests = server
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("failed to capture requests");
|
||||
assert_eq!(requests.len(), 1, "expected exactly one request");
|
||||
let payload: Value = serde_json::from_slice(&requests[0].body)?;
|
||||
let text = payload.get("text").expect("request missing text field");
|
||||
let format = text
|
||||
.get("format")
|
||||
|
||||
74
codex-rs/exec/tests/windows_sandbox.rs
Normal file
74
codex-rs/exec/tests/windows_sandbox.rs
Normal file
@@ -0,0 +1,74 @@
|
||||
#![cfg(windows)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_core::exec::ExecParams;
|
||||
use codex_core::exec::SandboxType;
|
||||
use codex_core::exec::process_exec_tool_call;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use codex_core::safety::set_windows_sandbox_enabled;
|
||||
|
||||
struct WindowsSandboxGuard;
|
||||
|
||||
impl WindowsSandboxGuard {
|
||||
fn enable() -> Self {
|
||||
set_windows_sandbox_enabled(true);
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WindowsSandboxGuard {
|
||||
fn drop(&mut self) {
|
||||
set_windows_sandbox_enabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
fn windows_workspace_policy(root: &PathBuf) -> SandboxPolicy {
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![root.clone()],
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: true,
|
||||
exclude_slash_tmp: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exec_tool_uses_windows_sandbox() {
|
||||
let _guard = WindowsSandboxGuard::enable();
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let cwd = temp.path().to_path_buf();
|
||||
let policy = windows_workspace_policy(&cwd);
|
||||
let params = ExecParams {
|
||||
command: vec![
|
||||
"cmd.exe".to_string(),
|
||||
"/C".to_string(),
|
||||
"set CODEX_SANDBOX".to_string(),
|
||||
],
|
||||
cwd: cwd.clone(),
|
||||
timeout_ms: None,
|
||||
env: HashMap::new(),
|
||||
with_escalated_permissions: None,
|
||||
justification: None,
|
||||
};
|
||||
|
||||
let output = process_exec_tool_call(
|
||||
params,
|
||||
SandboxType::WindowsAppContainer,
|
||||
&policy,
|
||||
temp.path(),
|
||||
&None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("exec output");
|
||||
|
||||
assert_eq!(output.exit_code, 0);
|
||||
assert!(
|
||||
output
|
||||
.aggregated_output
|
||||
.text
|
||||
.to_lowercase()
|
||||
.contains("codex_sandbox=windows_appcontainer")
|
||||
);
|
||||
}
|
||||
@@ -548,15 +548,10 @@ pub struct TaskStartedEvent {
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, Default, TS)]
|
||||
pub struct TokenUsage {
|
||||
#[ts(type = "number")]
|
||||
pub input_tokens: u64,
|
||||
#[ts(type = "number")]
|
||||
pub cached_input_tokens: u64,
|
||||
#[ts(type = "number")]
|
||||
pub output_tokens: u64,
|
||||
#[ts(type = "number")]
|
||||
pub reasoning_output_tokens: u64,
|
||||
#[ts(type = "number")]
|
||||
pub total_tokens: u64,
|
||||
}
|
||||
|
||||
@@ -564,7 +559,6 @@ pub struct TokenUsage {
|
||||
pub struct TokenUsageInfo {
|
||||
pub total_token_usage: TokenUsage,
|
||||
pub last_token_usage: TokenUsage,
|
||||
#[ts(type = "number | null")]
|
||||
pub model_context_window: Option<u64>,
|
||||
}
|
||||
|
||||
@@ -640,10 +634,8 @@ pub struct RateLimitWindow {
|
||||
/// Percentage (0-100) of the window that has been consumed.
|
||||
pub used_percent: f64,
|
||||
/// Rolling window duration, in minutes.
|
||||
#[ts(type = "number | null")]
|
||||
pub window_minutes: Option<u64>,
|
||||
/// Seconds until the window resets.
|
||||
#[ts(type = "number | null")]
|
||||
pub resets_in_seconds: Option<u64>,
|
||||
}
|
||||
|
||||
|
||||
@@ -68,8 +68,6 @@ strum_macros = { workspace = true }
|
||||
supports-color = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
textwrap = { workspace = true }
|
||||
tree-sitter-highlight = { workspace = true }
|
||||
tree-sitter-bash = { workspace = true }
|
||||
tokio = { workspace = true, features = [
|
||||
"io-std",
|
||||
"macros",
|
||||
|
||||
@@ -134,9 +134,8 @@ impl App {
|
||||
/// Useful when switching sessions to ensure prior history remains visible.
|
||||
pub(crate) fn render_transcript_once(&mut self, tui: &mut tui::Tui) {
|
||||
if !self.transcript_cells.is_empty() {
|
||||
let width = tui.terminal.last_known_screen_size.width;
|
||||
for cell in &self.transcript_cells {
|
||||
tui.insert_history_lines(cell.display_lines(width));
|
||||
tui.insert_history_lines(cell.transcript_lines());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,16 +315,15 @@ impl From<ApprovalRequest> for ApprovalRequestState {
|
||||
changes,
|
||||
} => {
|
||||
let mut header: Vec<Box<dyn Renderable>> = Vec::new();
|
||||
header.push(DiffSummary::new(changes, cwd).into());
|
||||
if let Some(reason) = reason
|
||||
&& !reason.is_empty()
|
||||
{
|
||||
header.push(Box::new(
|
||||
Paragraph::new(Line::from_iter(["Reason: ".into(), reason.italic()]))
|
||||
.wrap(Wrap { trim: false }),
|
||||
));
|
||||
header.push(Box::new(Line::from("")));
|
||||
header.push(Box::new(
|
||||
Paragraph::new(reason.italic()).wrap(Wrap { trim: false }),
|
||||
));
|
||||
}
|
||||
header.push(DiffSummary::new(changes, cwd).into());
|
||||
Self {
|
||||
variant: ApprovalVariant::ApplyPatch { id },
|
||||
header: Box::new(ColumnRenderable::new(header)),
|
||||
|
||||
@@ -38,6 +38,7 @@ use crate::bottom_pane::prompt_args::prompt_has_numeric_placeholders;
|
||||
use crate::slash_command::SlashCommand;
|
||||
use crate::slash_command::built_in_slash_commands;
|
||||
use crate::style::user_message_style;
|
||||
use crate::terminal_palette;
|
||||
use codex_protocol::custom_prompts::CustomPrompt;
|
||||
use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX;
|
||||
|
||||
@@ -1532,7 +1533,7 @@ impl WidgetRef for ChatComposer {
|
||||
}
|
||||
}
|
||||
}
|
||||
let style = user_message_style();
|
||||
let style = user_message_style(terminal_palette::default_bg());
|
||||
let mut block_rect = composer_rect;
|
||||
block_rect.y = composer_rect.y.saturating_sub(1);
|
||||
block_rect.height = composer_rect.height.saturating_add(1);
|
||||
|
||||
@@ -20,6 +20,7 @@ use crate::render::RectExt as _;
|
||||
use crate::render::renderable::ColumnRenderable;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::style::user_message_style;
|
||||
use crate::terminal_palette;
|
||||
|
||||
use super::CancellationEvent;
|
||||
use super::bottom_pane_view::BottomPaneView;
|
||||
@@ -349,7 +350,7 @@ impl Renderable for ListSelectionView {
|
||||
.areas(area);
|
||||
|
||||
Block::default()
|
||||
.style(user_message_style())
|
||||
.style(user_message_style(terminal_palette::default_bg()))
|
||||
.render(content_area, buf);
|
||||
|
||||
let header_height = self
|
||||
|
||||
@@ -81,7 +81,7 @@ pub(crate) struct BottomPaneParams {
|
||||
}
|
||||
|
||||
impl BottomPane {
|
||||
const BOTTOM_PAD_LINES: u16 = 0;
|
||||
const BOTTOM_PAD_LINES: u16 = 1;
|
||||
pub fn new(params: BottomPaneParams) -> Self {
|
||||
let enhanced_keys_supported = params.enhanced_keys_supported;
|
||||
Self {
|
||||
@@ -522,29 +522,10 @@ impl WidgetRef for &BottomPane {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app_event::AppEvent;
|
||||
use insta::assert_snapshot;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
fn snapshot_buffer(buf: &Buffer) -> String {
|
||||
let mut lines = Vec::new();
|
||||
for y in 0..buf.area().height {
|
||||
let mut row = String::new();
|
||||
for x in 0..buf.area().width {
|
||||
row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
|
||||
}
|
||||
lines.push(row);
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn render_snapshot(pane: &BottomPane, area: Rect) -> String {
|
||||
let mut buf = Buffer::empty(area);
|
||||
(&pane).render_ref(area, &mut buf);
|
||||
snapshot_buffer(&buf)
|
||||
}
|
||||
|
||||
fn exec_request() -> ApprovalRequest {
|
||||
ApprovalRequest::Exec {
|
||||
id: "1".to_string(),
|
||||
@@ -704,7 +685,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_and_composer_fill_height_without_bottom_padding() {
|
||||
fn bottom_padding_present_with_status_above_composer() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
@@ -719,21 +700,43 @@ mod tests {
|
||||
// Activate spinner (status view replaces composer) with no live ring.
|
||||
pane.set_task_running(true);
|
||||
|
||||
// Use height == desired_height; expect spacer + status + composer rows without trailing padding.
|
||||
// Use height == desired_height; expect 1 status row at top and 2 bottom padding rows.
|
||||
let height = pane.desired_height(30);
|
||||
assert!(
|
||||
height >= 3,
|
||||
"expected at least 3 rows to render spacer, status, and composer; got {height}"
|
||||
"expected at least 3 rows with bottom padding; got {height}"
|
||||
);
|
||||
let area = Rect::new(0, 0, 30, height);
|
||||
assert_snapshot!(
|
||||
"status_and_composer_fill_height_without_bottom_padding",
|
||||
render_snapshot(&pane, area)
|
||||
let mut buf = Buffer::empty(area);
|
||||
(&pane).render_ref(area, &mut buf);
|
||||
|
||||
// Row 1 contains the status header (row 0 is the spacer)
|
||||
let mut top = String::new();
|
||||
for x in 0..area.width {
|
||||
top.push(buf[(x, 1)].symbol().chars().next().unwrap_or(' '));
|
||||
}
|
||||
assert!(
|
||||
top.trim_start().starts_with("• Working"),
|
||||
"expected top row to start with '• Working': {top:?}"
|
||||
);
|
||||
assert!(
|
||||
top.contains("Working"),
|
||||
"expected Working header on top row: {top:?}"
|
||||
);
|
||||
|
||||
// Last row should be blank padding; the row above should generally contain composer content.
|
||||
let mut r_last = String::new();
|
||||
for x in 0..area.width {
|
||||
r_last.push(buf[(x, height - 1)].symbol().chars().next().unwrap_or(' '));
|
||||
}
|
||||
assert!(
|
||||
r_last.trim().is_empty(),
|
||||
"expected last row blank: {r_last:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_hidden_when_height_too_small() {
|
||||
fn bottom_padding_shrinks_when_tiny() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
@@ -747,18 +750,37 @@ mod tests {
|
||||
|
||||
pane.set_task_running(true);
|
||||
|
||||
// Height=2 → composer takes the full space; status collapses when there is no room.
|
||||
// Height=2 → status on one row, composer on the other.
|
||||
let area2 = Rect::new(0, 0, 20, 2);
|
||||
assert_snapshot!(
|
||||
"status_hidden_when_height_too_small_height_2",
|
||||
render_snapshot(&pane, area2)
|
||||
let mut buf2 = Buffer::empty(area2);
|
||||
(&pane).render_ref(area2, &mut buf2);
|
||||
let mut row0 = String::new();
|
||||
let mut row1 = String::new();
|
||||
for x in 0..area2.width {
|
||||
row0.push(buf2[(x, 0)].symbol().chars().next().unwrap_or(' '));
|
||||
row1.push(buf2[(x, 1)].symbol().chars().next().unwrap_or(' '));
|
||||
}
|
||||
let has_composer = row0.contains("Ask Codex") || row1.contains("Ask Codex");
|
||||
assert!(
|
||||
has_composer,
|
||||
"expected composer to be visible on one of the rows: row0={row0:?}, row1={row1:?}"
|
||||
);
|
||||
assert!(
|
||||
row0.contains("Working") || row1.contains("Working"),
|
||||
"expected status header to be visible at height=2: row0={row0:?}, row1={row1:?}"
|
||||
);
|
||||
|
||||
// Height=1 → no padding; single row is the composer (status hidden).
|
||||
let area1 = Rect::new(0, 0, 20, 1);
|
||||
assert_snapshot!(
|
||||
"status_hidden_when_height_too_small_height_1",
|
||||
render_snapshot(&pane, area1)
|
||||
let mut buf1 = Buffer::empty(area1);
|
||||
(&pane).render_ref(area1, &mut buf1);
|
||||
let mut only = String::new();
|
||||
for x in 0..area1.width {
|
||||
only.push(buf1[(x, 0)].symbol().chars().next().unwrap_or(' '));
|
||||
}
|
||||
assert!(
|
||||
only.contains("Ask Codex"),
|
||||
"expected composer with no padding: {only:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/mod.rs
|
||||
expression: "render_snapshot(&pane, area)"
|
||||
---
|
||||
|
||||
• Working (0s • esc to interru
|
||||
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
? for shortcuts
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/mod.rs
|
||||
expression: "render_snapshot(&pane, area1)"
|
||||
---
|
||||
› Ask Codex to do an
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/mod.rs
|
||||
expression: "render_snapshot(&pane, area2)"
|
||||
---
|
||||
|
||||
› Ask Codex to do an
|
||||
@@ -3,4 +3,4 @@ source: tui/src/chatwidget/tests.rs
|
||||
expression: lines_to_single_string(&approved_lines)
|
||||
---
|
||||
• Added foo.txt (+1 -0)
|
||||
1 +hello
|
||||
1 +hello
|
||||
|
||||
@@ -4,12 +4,12 @@ expression: terminal.backend().vt100().screen().contents()
|
||||
---
|
||||
Would you like to make the following edits?
|
||||
|
||||
Reason: The model wants to apply changes
|
||||
|
||||
README.md (+2 -0)
|
||||
|
||||
1 +hello
|
||||
2 +world
|
||||
1 +hello
|
||||
2 +world
|
||||
|
||||
The model wants to apply changes
|
||||
|
||||
› 1. Yes, proceed
|
||||
2. No, and tell Codex what to do differently esc
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 1470
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
" "
|
||||
"› Ask Codex to do anything "
|
||||
" "
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 1500
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"• Thinking (0s • esc to interrupt) "
|
||||
"› Ask Codex to do anything "
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 1500
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"• Thinking (0s • esc to interrupt) "
|
||||
"› Ask Codex to do anything "
|
||||
" "
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 409
|
||||
expression: "format!(\"{buf:?}\")"
|
||||
---
|
||||
Buffer {
|
||||
area: Rect { x: 0, y: 0, width: 80, height: 14 },
|
||||
area: Rect { x: 0, y: 0, width: 80, height: 15 },
|
||||
content: [
|
||||
" ",
|
||||
" ",
|
||||
@@ -20,6 +19,7 @@ Buffer {
|
||||
" 3. No, and tell Codex what to do differently esc ",
|
||||
" ",
|
||||
" Press enter to confirm or esc to cancel ",
|
||||
" ",
|
||||
],
|
||||
styles: [
|
||||
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
@@ -34,5 +34,6 @@ Buffer {
|
||||
x: 47, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 50, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 2, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 0, y: 14, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 1577
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
@@ -10,3 +9,4 @@ expression: terminal.backend()
|
||||
"› Ask Codex to do anything "
|
||||
" "
|
||||
" ? for shortcuts "
|
||||
" "
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 1548
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
@@ -17,3 +16,4 @@ expression: terminal.backend()
|
||||
" 3. No, and tell Codex what to do differently esc "
|
||||
" "
|
||||
" Press enter to confirm or esc to cancel "
|
||||
" "
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::path::PathBuf;
|
||||
#[command(version)]
|
||||
pub struct Cli {
|
||||
/// Optional user prompt to start the session.
|
||||
#[arg(value_name = "PROMPT", value_hint = clap::ValueHint::Other)]
|
||||
#[arg(value_name = "PROMPT")]
|
||||
pub prompt: Option<String>,
|
||||
|
||||
/// Optional image(s) to attach to the initial prompt.
|
||||
|
||||
@@ -120,8 +120,6 @@ where
|
||||
/// Last known position of the cursor. Used to find the new area when the viewport is inlined
|
||||
/// and the terminal resized.
|
||||
pub last_known_cursor_pos: Position,
|
||||
|
||||
use_custom_flush: bool,
|
||||
}
|
||||
|
||||
impl<B> Drop for Terminal<B>
|
||||
@@ -160,7 +158,6 @@ where
|
||||
viewport_area: Rect::new(0, cursor_pos.y, 0, 0),
|
||||
last_known_screen_size: screen_size,
|
||||
last_known_cursor_pos: cursor_pos,
|
||||
use_custom_flush: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -193,24 +190,15 @@ where
|
||||
pub fn flush(&mut self) -> io::Result<()> {
|
||||
let previous_buffer = &self.buffers[1 - self.current];
|
||||
let current_buffer = &self.buffers[self.current];
|
||||
|
||||
if self.use_custom_flush {
|
||||
let updates = diff_buffers(previous_buffer, current_buffer);
|
||||
if let Some(DrawCommand::Put { x, y, .. }) = updates
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|cmd| matches!(cmd, DrawCommand::Put { .. }))
|
||||
{
|
||||
self.last_known_cursor_pos = Position { x: *x, y: *y };
|
||||
}
|
||||
draw(&mut self.backend, updates.into_iter())
|
||||
} else {
|
||||
let updates = previous_buffer.diff(current_buffer);
|
||||
if let Some((x, y, _)) = updates.last() {
|
||||
self.last_known_cursor_pos = Position { x: *x, y: *y };
|
||||
}
|
||||
self.backend.draw(updates.into_iter())
|
||||
let updates = diff_buffers(previous_buffer, current_buffer);
|
||||
if let Some(DrawCommand::Put { x, y, .. }) = updates
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|cmd| matches!(cmd, DrawCommand::Put { .. }))
|
||||
{
|
||||
self.last_known_cursor_pos = Position { x: *x, y: *y };
|
||||
}
|
||||
draw(&mut self.backend, updates.into_iter())
|
||||
}
|
||||
|
||||
/// Updates the Terminal so that internal buffers match the requested area.
|
||||
@@ -420,13 +408,11 @@ fn diff_buffers<'a>(a: &'a Buffer, b: &'a Buffer) -> Vec<DrawCommand<'a>> {
|
||||
|
||||
let x = row
|
||||
.iter()
|
||||
.rposition(|cell| {
|
||||
cell.symbol() != " " || cell.bg != bg || cell.modifier != Modifier::empty()
|
||||
})
|
||||
.rposition(|cell| cell.symbol() != " " || cell.bg != bg)
|
||||
.unwrap_or(0);
|
||||
last_nonblank_column[y as usize] = x as u16;
|
||||
let (x_abs, y_abs) = a.pos_of(row_start + x + 1);
|
||||
if x < (a.area.width as usize).saturating_sub(1) {
|
||||
let (x_abs, y_abs) = a.pos_of(row_start + x + 1);
|
||||
updates.push(DrawCommand::ClearToEnd {
|
||||
x: x_abs,
|
||||
y: y_abs,
|
||||
|
||||
@@ -13,14 +13,13 @@ use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::exec_command::relativize_to_home;
|
||||
use crate::render::Insets;
|
||||
use crate::render::line_utils::prefix_lines;
|
||||
use crate::render::renderable::ColumnRenderable;
|
||||
use crate::render::renderable::InsetRenderable;
|
||||
use crate::render::renderable::Renderable;
|
||||
use codex_core::git_info::get_git_repo_root;
|
||||
use codex_core::protocol::FileChange;
|
||||
|
||||
const SPACES_AFTER_LINE_NUMBER: usize = 6;
|
||||
|
||||
// Internal representation for diff line rendering
|
||||
enum DiffLineType {
|
||||
Insert,
|
||||
@@ -66,10 +65,7 @@ impl From<DiffSummary> for Box<dyn Renderable> {
|
||||
path.extend(render_line_count_summary(row.added, row.removed));
|
||||
rows.push(Box::new(path));
|
||||
rows.push(Box::new(RtLine::from("")));
|
||||
rows.push(Box::new(InsetRenderable::new(
|
||||
row.change,
|
||||
Insets::tlbr(0, 2, 0, 0),
|
||||
)));
|
||||
rows.push(Box::new(row.change));
|
||||
}
|
||||
|
||||
Box::new(ColumnRenderable::new(rows))
|
||||
@@ -185,9 +181,7 @@ fn render_changes_block(rows: Vec<Row>, wrap_cols: usize, cwd: &Path) -> Vec<RtL
|
||||
out.push(RtLine::from(header));
|
||||
}
|
||||
|
||||
let mut lines = vec![];
|
||||
render_change(&r.change, &mut lines, wrap_cols - 4);
|
||||
out.extend(prefix_lines(lines, " ".into(), " ".into()));
|
||||
render_change(&r.change, &mut out, wrap_cols);
|
||||
}
|
||||
|
||||
out
|
||||
@@ -196,60 +190,31 @@ fn render_changes_block(rows: Vec<Row>, wrap_cols: usize, cwd: &Path) -> Vec<RtL
|
||||
fn render_change(change: &FileChange, out: &mut Vec<RtLine<'static>>, width: usize) {
|
||||
match change {
|
||||
FileChange::Add { content } => {
|
||||
let line_number_width = line_number_width(content.lines().count());
|
||||
for (i, raw) in content.lines().enumerate() {
|
||||
out.extend(push_wrapped_diff_line(
|
||||
i + 1,
|
||||
DiffLineType::Insert,
|
||||
raw,
|
||||
width,
|
||||
line_number_width,
|
||||
));
|
||||
}
|
||||
}
|
||||
FileChange::Delete { content } => {
|
||||
let line_number_width = line_number_width(content.lines().count());
|
||||
for (i, raw) in content.lines().enumerate() {
|
||||
out.extend(push_wrapped_diff_line(
|
||||
i + 1,
|
||||
DiffLineType::Delete,
|
||||
raw,
|
||||
width,
|
||||
line_number_width,
|
||||
));
|
||||
}
|
||||
}
|
||||
FileChange::Update { unified_diff, .. } => {
|
||||
if let Ok(patch) = diffy::Patch::from_str(unified_diff) {
|
||||
let mut max_line_number = 0;
|
||||
for h in patch.hunks() {
|
||||
let mut old_ln = h.old_range().start();
|
||||
let mut new_ln = h.new_range().start();
|
||||
for l in h.lines() {
|
||||
match l {
|
||||
diffy::Line::Insert(_) => {
|
||||
max_line_number = max_line_number.max(new_ln);
|
||||
new_ln += 1;
|
||||
}
|
||||
diffy::Line::Delete(_) => {
|
||||
max_line_number = max_line_number.max(old_ln);
|
||||
old_ln += 1;
|
||||
}
|
||||
diffy::Line::Context(_) => {
|
||||
max_line_number = max_line_number.max(new_ln);
|
||||
old_ln += 1;
|
||||
new_ln += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let line_number_width = line_number_width(max_line_number);
|
||||
let mut is_first_hunk = true;
|
||||
for h in patch.hunks() {
|
||||
if !is_first_hunk {
|
||||
let spacer = format!("{:width$} ", "", width = line_number_width.max(1));
|
||||
let spacer_span = RtSpan::styled(spacer, style_gutter());
|
||||
out.push(RtLine::from(vec![spacer_span, "⋮".dim()]));
|
||||
out.push(RtLine::from(vec![" ".into(), "⋮".dim()]));
|
||||
}
|
||||
is_first_hunk = false;
|
||||
|
||||
@@ -264,7 +229,6 @@ fn render_change(change: &FileChange, out: &mut Vec<RtLine<'static>>, width: usi
|
||||
DiffLineType::Insert,
|
||||
s,
|
||||
width,
|
||||
line_number_width,
|
||||
));
|
||||
new_ln += 1;
|
||||
}
|
||||
@@ -275,7 +239,6 @@ fn render_change(change: &FileChange, out: &mut Vec<RtLine<'static>>, width: usi
|
||||
DiffLineType::Delete,
|
||||
s,
|
||||
width,
|
||||
line_number_width,
|
||||
));
|
||||
old_ln += 1;
|
||||
}
|
||||
@@ -286,7 +249,6 @@ fn render_change(change: &FileChange, out: &mut Vec<RtLine<'static>>, width: usi
|
||||
DiffLineType::Context,
|
||||
s,
|
||||
width,
|
||||
line_number_width,
|
||||
));
|
||||
old_ln += 1;
|
||||
new_ln += 1;
|
||||
@@ -336,15 +298,17 @@ fn push_wrapped_diff_line(
|
||||
kind: DiffLineType,
|
||||
text: &str,
|
||||
width: usize,
|
||||
line_number_width: usize,
|
||||
) -> Vec<RtLine<'static>> {
|
||||
let indent = " ";
|
||||
let ln_str = line_number.to_string();
|
||||
let mut remaining_text: &str = text;
|
||||
|
||||
// Reserve a fixed number of spaces (equal to the widest line number plus a
|
||||
// trailing spacer) so the sign column stays aligned across the diff block.
|
||||
let gutter_width = line_number_width.max(1);
|
||||
let prefix_cols = gutter_width + 1;
|
||||
// Reserve a fixed number of spaces after the line number so that content starts
|
||||
// at a consistent column. Content includes a 1-character diff sign prefix
|
||||
// ("+"/"-" for inserts/deletes, or a space for context lines) so alignment
|
||||
// stays consistent across all diff lines.
|
||||
let gap_after_ln = SPACES_AFTER_LINE_NUMBER.saturating_sub(ln_str.len());
|
||||
let prefix_cols = indent.len() + ln_str.len() + gap_after_ln;
|
||||
|
||||
let mut first = true;
|
||||
let (sign_char, line_style) = match kind {
|
||||
@@ -368,8 +332,8 @@ fn push_wrapped_diff_line(
|
||||
remaining_text = rest;
|
||||
|
||||
if first {
|
||||
// Build gutter (right-aligned line number plus spacer) as a dimmed span
|
||||
let gutter = format!("{ln_str:>gutter_width$} ");
|
||||
// Build gutter (indent + line number + spacing) as a dimmed span
|
||||
let gutter = format!("{indent}{ln_str}{}", " ".repeat(gap_after_ln));
|
||||
// Content with a sign ('+'/'-'/' ') styled per diff kind
|
||||
let content = format!("{sign_char}{chunk}");
|
||||
lines.push(RtLine::from(vec![
|
||||
@@ -379,7 +343,7 @@ fn push_wrapped_diff_line(
|
||||
first = false;
|
||||
} else {
|
||||
// Continuation lines keep a space for the sign column so content aligns
|
||||
let gutter = format!("{:gutter_width$} ", "");
|
||||
let gutter = format!("{indent}{} ", " ".repeat(ln_str.len() + gap_after_ln));
|
||||
lines.push(RtLine::from(vec![
|
||||
RtSpan::styled(gutter, style_gutter()),
|
||||
RtSpan::styled(chunk.to_string(), line_style),
|
||||
@@ -392,14 +356,6 @@ fn push_wrapped_diff_line(
|
||||
lines
|
||||
}
|
||||
|
||||
fn line_number_width(max_line_number: usize) -> usize {
|
||||
if max_line_number == 0 {
|
||||
1
|
||||
} else {
|
||||
max_line_number.to_string().len()
|
||||
}
|
||||
}
|
||||
|
||||
fn style_gutter() -> Style {
|
||||
Style::default().add_modifier(Modifier::DIM)
|
||||
}
|
||||
@@ -465,8 +421,7 @@ mod tests {
|
||||
let long_line = "this is a very long line that should wrap across multiple terminal columns and continue";
|
||||
|
||||
// Call the wrapping function directly so we can precisely control the width
|
||||
let lines =
|
||||
push_wrapped_diff_line(1, DiffLineType::Insert, long_line, 80, line_number_width(1));
|
||||
let lines = push_wrapped_diff_line(1, DiffLineType::Insert, long_line, 80);
|
||||
|
||||
// Render into a small terminal to capture the visual layout
|
||||
snapshot_lines("wrap_behavior_insert", lines, 90, 8);
|
||||
@@ -487,9 +442,11 @@ mod tests {
|
||||
},
|
||||
);
|
||||
|
||||
let lines = diff_summary_for_tests(&changes);
|
||||
for name in ["apply_update_block", "apply_update_block_manual"] {
|
||||
let lines = diff_summary_for_tests(&changes);
|
||||
|
||||
snapshot_lines("apply_update_block", lines, 80, 12);
|
||||
snapshot_lines(name, lines, 80, 12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -616,37 +573,14 @@ mod tests {
|
||||
},
|
||||
);
|
||||
|
||||
let lines = create_diff_summary(&changes, &PathBuf::from("/"), 28);
|
||||
let mut lines = create_diff_summary(&changes, &PathBuf::from("/"), 28);
|
||||
// Drop the combined header for this text-only snapshot
|
||||
if !lines.is_empty() {
|
||||
lines.remove(0);
|
||||
}
|
||||
snapshot_lines_text("apply_update_block_wraps_long_lines_text", &lines);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_snapshot_apply_update_block_line_numbers_three_digits_text() {
|
||||
let original = (1..=110).map(|i| format!("line {i}\n")).collect::<String>();
|
||||
let modified = (1..=110)
|
||||
.map(|i| {
|
||||
if i == 100 {
|
||||
format!("line {i} changed\n")
|
||||
} else {
|
||||
format!("line {i}\n")
|
||||
}
|
||||
})
|
||||
.collect::<String>();
|
||||
let patch = diffy::create_patch(&original, &modified).to_string();
|
||||
|
||||
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
|
||||
changes.insert(
|
||||
PathBuf::from("hundreds.txt"),
|
||||
FileChange::Update {
|
||||
unified_diff: patch,
|
||||
move_path: None,
|
||||
},
|
||||
);
|
||||
|
||||
let lines = create_diff_summary(&changes, &PathBuf::from("/"), 80);
|
||||
snapshot_lines_text("apply_update_block_line_numbers_three_digits_text", &lines);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_snapshot_apply_update_block_relativizes_path() {
|
||||
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
|
||||
|
||||
@@ -8,10 +8,8 @@ use crate::history_cell::HistoryCell;
|
||||
use crate::render::highlight::highlight_bash_to_lines;
|
||||
use crate::render::line_utils::prefix_lines;
|
||||
use crate::render::line_utils::push_owned_lines;
|
||||
use crate::shimmer::shimmer_spans;
|
||||
use crate::wrapping::RtOptions;
|
||||
use crate::wrapping::word_wrap_line;
|
||||
use crate::wrapping::word_wrap_lines;
|
||||
use codex_ansi_escape::ansi_escape_line;
|
||||
use codex_common::elapsed::format_duration;
|
||||
use codex_protocol::parse_command::ParsedCommand;
|
||||
@@ -118,16 +116,10 @@ pub(crate) fn output_lines(
|
||||
}
|
||||
|
||||
pub(crate) fn spinner(start_time: Option<Instant>) -> Span<'static> {
|
||||
let elapsed = start_time.map(|st| st.elapsed()).unwrap_or_default();
|
||||
if supports_color::on_cached(supports_color::Stream::Stdout)
|
||||
.map(|level| level.has_16m)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
shimmer_spans("•")[0].clone()
|
||||
} else {
|
||||
let blink_on = (elapsed.as_millis() / 600).is_multiple_of(2);
|
||||
if blink_on { "•".into() } else { "◦".dim() }
|
||||
}
|
||||
let blink_on = start_time
|
||||
.map(|st| ((st.elapsed().as_millis() / 600) % 2) == 0)
|
||||
.unwrap_or(false);
|
||||
if blink_on { "•".into() } else { "◦".dim() }
|
||||
}
|
||||
|
||||
impl HistoryCell for ExecCell {
|
||||
@@ -139,25 +131,17 @@ impl HistoryCell for ExecCell {
|
||||
}
|
||||
}
|
||||
|
||||
fn desired_transcript_height(&self, width: u16) -> u16 {
|
||||
self.transcript_lines(width).len() as u16
|
||||
}
|
||||
|
||||
fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
fn transcript_lines(&self) -> Vec<Line<'static>> {
|
||||
let mut lines: Vec<Line<'static>> = vec![];
|
||||
for (i, call) in self.iter_calls().enumerate() {
|
||||
if i > 0 {
|
||||
lines.push("".into());
|
||||
for call in self.iter_calls() {
|
||||
let cmd_display = strip_bash_lc_and_escape(&call.command);
|
||||
for (i, part) in cmd_display.lines().enumerate() {
|
||||
if i == 0 {
|
||||
lines.push(vec!["$ ".magenta(), part.to_string().into()].into());
|
||||
} else {
|
||||
lines.push(vec![" ".into(), part.to_string().into()].into());
|
||||
}
|
||||
}
|
||||
let script = strip_bash_lc_and_escape(&call.command);
|
||||
let highlighted_script = highlight_bash_to_lines(&script);
|
||||
let cmd_display = word_wrap_lines(
|
||||
&highlighted_script,
|
||||
RtOptions::new(width as usize)
|
||||
.initial_indent("$ ".magenta().into())
|
||||
.subsequent_indent(" ".into()),
|
||||
);
|
||||
lines.extend(cmd_display);
|
||||
|
||||
if let Some(output) = call.output.as_ref() {
|
||||
lines.extend(output.formatted_output.lines().map(ansi_escape_line));
|
||||
@@ -176,6 +160,7 @@ impl HistoryCell for ExecCell {
|
||||
result.push_span(format!(" • {duration}").dim());
|
||||
lines.push(result);
|
||||
}
|
||||
lines.push("".into());
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use crate::markdown::append_markdown;
|
||||
use crate::render::line_utils::line_to_static;
|
||||
use crate::render::line_utils::prefix_lines;
|
||||
use crate::style::user_message_style;
|
||||
use crate::terminal_palette::default_bg;
|
||||
use crate::text_formatting::format_and_truncate_tool_result;
|
||||
use crate::ui_consts::LIVE_PREFIX_COLS;
|
||||
use crate::wrapping::RtOptions;
|
||||
@@ -55,6 +56,10 @@ use unicode_width::UnicodeWidthStr;
|
||||
pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any {
|
||||
fn display_lines(&self, width: u16) -> Vec<Line<'static>>;
|
||||
|
||||
fn transcript_lines(&self) -> Vec<Line<'static>> {
|
||||
self.display_lines(u16::MAX)
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
Paragraph::new(Text::from(self.display_lines(width)))
|
||||
.wrap(Wrap { trim: false })
|
||||
@@ -63,29 +68,6 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
self.display_lines(width)
|
||||
}
|
||||
|
||||
fn desired_transcript_height(&self, width: u16) -> u16 {
|
||||
let lines = self.transcript_lines(width);
|
||||
// Workaround for ratatui bug: if there's only one line and it's whitespace-only, ratatui gives 2 lines.
|
||||
if let [line] = &lines[..]
|
||||
&& line
|
||||
.spans
|
||||
.iter()
|
||||
.all(|s| s.content.chars().all(char::is_whitespace))
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
Paragraph::new(Text::from(lines))
|
||||
.wrap(Wrap { trim: false })
|
||||
.line_count(width)
|
||||
.try_into()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn is_stream_continuation(&self) -> bool {
|
||||
false
|
||||
}
|
||||
@@ -110,18 +92,19 @@ impl HistoryCell for UserHistoryCell {
|
||||
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
|
||||
let wrap_width = width.saturating_sub(LIVE_PREFIX_COLS);
|
||||
// Use ratatui-aware word wrapping and prefixing to avoid lifetime issues.
|
||||
let wrap_width = width.saturating_sub(LIVE_PREFIX_COLS); // account for the ▌ prefix and trailing space
|
||||
|
||||
let style = user_message_style();
|
||||
let style = user_message_style(default_bg());
|
||||
|
||||
// Use our ratatui wrapping helpers for correct styling and lifetimes.
|
||||
let wrapped = word_wrap_lines(
|
||||
&self
|
||||
.message
|
||||
.lines()
|
||||
.map(|l| Line::from(l).style(style))
|
||||
.collect::<Vec<_>>(),
|
||||
// Wrap algorithm matches textarea.rs.
|
||||
RtOptions::new(wrap_width as usize).wrap_algorithm(textwrap::WrapAlgorithm::FirstFit),
|
||||
RtOptions::new(wrap_width as usize),
|
||||
);
|
||||
|
||||
lines.push(Line::from("").style(style));
|
||||
@@ -129,6 +112,13 @@ impl HistoryCell for UserHistoryCell {
|
||||
lines.push(Line::from("").style(style));
|
||||
lines
|
||||
}
|
||||
|
||||
fn transcript_lines(&self) -> Vec<Line<'static>> {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
lines.push("user".cyan().bold().into());
|
||||
lines.extend(self.message.lines().map(|l| l.to_string().into()));
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -136,7 +126,6 @@ pub(crate) struct ReasoningSummaryCell {
|
||||
_header: String,
|
||||
content: String,
|
||||
citation_context: MarkdownCitationContext,
|
||||
transcript_only: bool,
|
||||
}
|
||||
|
||||
impl ReasoningSummaryCell {
|
||||
@@ -144,17 +133,17 @@ impl ReasoningSummaryCell {
|
||||
header: String,
|
||||
content: String,
|
||||
citation_context: MarkdownCitationContext,
|
||||
transcript_only: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
_header: header,
|
||||
content,
|
||||
citation_context,
|
||||
transcript_only,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
impl HistoryCell for ReasoningSummaryCell {
|
||||
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
append_markdown(
|
||||
&self.content,
|
||||
@@ -162,7 +151,7 @@ impl ReasoningSummaryCell {
|
||||
&mut lines,
|
||||
self.citation_context.clone(),
|
||||
);
|
||||
let summary_style = Style::default().dim().italic();
|
||||
let summary_style = Style::default().add_modifier(Modifier::DIM | Modifier::ITALIC);
|
||||
let summary_lines = lines
|
||||
.into_iter()
|
||||
.map(|mut line| {
|
||||
@@ -182,31 +171,19 @@ impl ReasoningSummaryCell {
|
||||
.subsequent_indent(" ".into()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryCell for ReasoningSummaryCell {
|
||||
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
if self.transcript_only {
|
||||
Vec::new()
|
||||
} else {
|
||||
self.lines(width)
|
||||
}
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
if self.transcript_only {
|
||||
0
|
||||
} else {
|
||||
self.lines(width).len() as u16
|
||||
}
|
||||
}
|
||||
|
||||
fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
self.lines(width)
|
||||
}
|
||||
|
||||
fn desired_transcript_height(&self, width: u16) -> u16 {
|
||||
self.lines(width).len() as u16
|
||||
fn transcript_lines(&self) -> Vec<Line<'static>> {
|
||||
let mut out: Vec<Line<'static>> = Vec::new();
|
||||
out.push("thinking".magenta().bold().into());
|
||||
let mut lines = Vec::new();
|
||||
append_markdown(
|
||||
&self.content,
|
||||
None,
|
||||
&mut lines,
|
||||
self.citation_context.clone(),
|
||||
);
|
||||
out.extend(lines);
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,6 +216,15 @@ impl HistoryCell for AgentMessageCell {
|
||||
)
|
||||
}
|
||||
|
||||
fn transcript_lines(&self) -> Vec<Line<'static>> {
|
||||
let mut out: Vec<Line<'static>> = Vec::new();
|
||||
if self.is_first_line {
|
||||
out.push("codex".magenta().bold().into());
|
||||
}
|
||||
out.extend(self.lines.clone());
|
||||
out
|
||||
}
|
||||
|
||||
fn is_stream_continuation(&self) -> bool {
|
||||
!self.is_first_line
|
||||
}
|
||||
@@ -261,6 +247,21 @@ impl HistoryCell for PlainHistoryCell {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct TranscriptOnlyHistoryCell {
|
||||
lines: Vec<Line<'static>>,
|
||||
}
|
||||
|
||||
impl HistoryCell for TranscriptOnlyHistoryCell {
|
||||
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn transcript_lines(&self) -> Vec<Line<'static>> {
|
||||
self.lines.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Cyan history cell line showing the current review status.
|
||||
pub(crate) fn new_review_status_line(message: String) -> PlainHistoryCell {
|
||||
PlainHistoryCell {
|
||||
@@ -1048,6 +1049,16 @@ pub(crate) fn new_view_image_tool_call(path: PathBuf, cwd: &Path) -> PlainHistor
|
||||
PlainHistoryCell { lines }
|
||||
}
|
||||
|
||||
pub(crate) fn new_reasoning_block(
|
||||
full_reasoning_buffer: String,
|
||||
config: &Config,
|
||||
) -> TranscriptOnlyHistoryCell {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
lines.push(Line::from("thinking".magenta().italic()));
|
||||
append_markdown(&full_reasoning_buffer, None, &mut lines, config);
|
||||
TranscriptOnlyHistoryCell { lines }
|
||||
}
|
||||
|
||||
pub(crate) fn new_reasoning_summary_block(
|
||||
full_reasoning_buffer: String,
|
||||
config: &Config,
|
||||
@@ -1073,18 +1084,12 @@ pub(crate) fn new_reasoning_summary_block(
|
||||
header_buffer,
|
||||
summary_buffer,
|
||||
config.into(),
|
||||
false,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Box::new(ReasoningSummaryCell::new(
|
||||
"".to_string(),
|
||||
full_reasoning_buffer,
|
||||
config.into(),
|
||||
true,
|
||||
))
|
||||
Box::new(new_reasoning_block(full_reasoning_buffer, config))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -1115,6 +1120,10 @@ impl HistoryCell for FinalMessageSeparator {
|
||||
vec![Line::from_iter(["─".repeat(width as usize).dim()])]
|
||||
}
|
||||
}
|
||||
|
||||
fn transcript_lines(&self) -> Vec<Line<'static>> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> {
|
||||
@@ -1178,14 +1187,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn render_transcript(cell: &dyn HistoryCell) -> Vec<String> {
|
||||
render_lines(&cell.transcript_lines(u16::MAX))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_agent_message_cell_transcript() {
|
||||
let cell = AgentMessageCell::new(vec![Line::default()], false);
|
||||
assert_eq!(cell.transcript_lines(80), vec![Line::from(" ")]);
|
||||
assert_eq!(cell.desired_transcript_height(80), 1);
|
||||
render_lines(&cell.transcript_lines())
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1880,7 +1882,10 @@ mod tests {
|
||||
assert_eq!(rendered_display, vec!["• Detailed reasoning goes here."]);
|
||||
|
||||
let rendered_transcript = render_transcript(cell.as_ref());
|
||||
assert_eq!(rendered_transcript, vec!["• Detailed reasoning goes here."]);
|
||||
assert_eq!(
|
||||
rendered_transcript,
|
||||
vec!["thinking", "Detailed reasoning goes here."]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1892,7 +1897,7 @@ mod tests {
|
||||
new_reasoning_summary_block("Detailed reasoning goes here.".to_string(), &config);
|
||||
|
||||
let rendered = render_transcript(cell.as_ref());
|
||||
assert_eq!(rendered, vec!["• Detailed reasoning goes here."]);
|
||||
assert_eq!(rendered, vec!["thinking", "Detailed reasoning goes here."]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1906,7 +1911,10 @@ mod tests {
|
||||
);
|
||||
|
||||
let rendered = render_transcript(cell.as_ref());
|
||||
assert_eq!(rendered, vec!["• **High level reasoning without closing"]);
|
||||
assert_eq!(
|
||||
rendered,
|
||||
vec!["thinking", "**High level reasoning without closing"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1920,7 +1928,10 @@ mod tests {
|
||||
);
|
||||
|
||||
let rendered = render_transcript(cell.as_ref());
|
||||
assert_eq!(rendered, vec!["• High level reasoning without closing"]);
|
||||
assert_eq!(
|
||||
rendered,
|
||||
vec!["thinking", "High level reasoning without closing"]
|
||||
);
|
||||
|
||||
let cell = new_reasoning_summary_block(
|
||||
"**High level reasoning without closing**\n\n ".to_string(),
|
||||
@@ -1928,7 +1939,10 @@ mod tests {
|
||||
);
|
||||
|
||||
let rendered = render_transcript(cell.as_ref());
|
||||
assert_eq!(rendered, vec!["• High level reasoning without closing"]);
|
||||
assert_eq!(
|
||||
rendered,
|
||||
vec!["thinking", "High level reasoning without closing"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1945,6 +1959,9 @@ mod tests {
|
||||
assert_eq!(rendered_display, vec!["• We should fix the bug next."]);
|
||||
|
||||
let rendered_transcript = render_transcript(cell.as_ref());
|
||||
assert_eq!(rendered_transcript, vec!["• We should fix the bug next."]);
|
||||
assert_eq!(
|
||||
rendered_transcript,
|
||||
vec!["thinking", "We should fix the bug next."]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,9 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::UserHistoryCell;
|
||||
use crate::key_hint;
|
||||
use crate::key_hint::KeyBinding;
|
||||
use crate::render::Insets;
|
||||
use crate::render::renderable::InsetRenderable;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::style::user_message_style;
|
||||
use crate::tui;
|
||||
use crate::tui::TuiEvent;
|
||||
use crossterm::event::KeyCode;
|
||||
@@ -17,7 +13,6 @@ use crossterm::event::KeyEvent;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::buffer::Cell;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
@@ -26,6 +21,7 @@ use ratatui::widgets::Clear;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use ratatui::widgets::Wrap;
|
||||
|
||||
pub(crate) enum Overlay {
|
||||
Transcript(TranscriptOverlay),
|
||||
@@ -321,30 +317,29 @@ impl PagerView {
|
||||
}
|
||||
}
|
||||
|
||||
/// A renderable that caches its desired height.
|
||||
struct CachedRenderable {
|
||||
renderable: Box<dyn Renderable>,
|
||||
struct CachedParagraph {
|
||||
paragraph: Paragraph<'static>,
|
||||
height: std::cell::Cell<Option<u16>>,
|
||||
last_width: std::cell::Cell<Option<u16>>,
|
||||
}
|
||||
|
||||
impl CachedRenderable {
|
||||
fn new(renderable: Box<dyn Renderable>) -> Self {
|
||||
impl CachedParagraph {
|
||||
fn new(paragraph: Paragraph<'static>) -> Self {
|
||||
Self {
|
||||
renderable,
|
||||
paragraph,
|
||||
height: std::cell::Cell::new(None),
|
||||
last_width: std::cell::Cell::new(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderable for CachedRenderable {
|
||||
impl Renderable for CachedParagraph {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
self.renderable.render(area, buf);
|
||||
self.paragraph.render_ref(area, buf);
|
||||
}
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
if self.last_width.get() != Some(width) {
|
||||
let height = self.renderable.desired_height(width);
|
||||
let height = self.paragraph.line_count(width) as u16;
|
||||
self.height.set(Some(height));
|
||||
self.last_width.set(Some(width));
|
||||
}
|
||||
@@ -352,23 +347,6 @@ impl Renderable for CachedRenderable {
|
||||
}
|
||||
}
|
||||
|
||||
struct CellRenderable {
|
||||
cell: Arc<dyn HistoryCell>,
|
||||
style: Style,
|
||||
}
|
||||
|
||||
impl Renderable for CellRenderable {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
let p =
|
||||
Paragraph::new(Text::from(self.cell.transcript_lines(area.width))).style(self.style);
|
||||
p.render(area, buf);
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
self.cell.desired_transcript_height(width)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct TranscriptOverlay {
|
||||
view: PagerView,
|
||||
cells: Vec<Arc<dyn HistoryCell>>,
|
||||
@@ -380,7 +358,7 @@ impl TranscriptOverlay {
|
||||
pub(crate) fn new(transcript_cells: Vec<Arc<dyn HistoryCell>>) -> Self {
|
||||
Self {
|
||||
view: PagerView::new(
|
||||
Self::render_cells(&transcript_cells, None),
|
||||
Self::render_cells_to_texts(&transcript_cells, None),
|
||||
"T R A N S C R I P T".to_string(),
|
||||
usize::MAX,
|
||||
),
|
||||
@@ -390,46 +368,46 @@ impl TranscriptOverlay {
|
||||
}
|
||||
}
|
||||
|
||||
fn render_cells(
|
||||
fn render_cells_to_texts(
|
||||
cells: &[Arc<dyn HistoryCell>],
|
||||
highlight_cell: Option<usize>,
|
||||
) -> Vec<Box<dyn Renderable>> {
|
||||
cells
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(i, c)| {
|
||||
let mut v: Vec<Box<dyn Renderable>> = Vec::new();
|
||||
let mut cell_renderable = if c.as_any().is::<UserHistoryCell>() {
|
||||
Box::new(CachedRenderable::new(Box::new(CellRenderable {
|
||||
cell: c.clone(),
|
||||
style: if highlight_cell == Some(i) {
|
||||
user_message_style().reversed()
|
||||
} else {
|
||||
user_message_style()
|
||||
},
|
||||
}))) as Box<dyn Renderable>
|
||||
} else {
|
||||
Box::new(CachedRenderable::new(Box::new(CellRenderable {
|
||||
cell: c.clone(),
|
||||
style: Style::default(),
|
||||
}))) as Box<dyn Renderable>
|
||||
};
|
||||
if !c.is_stream_continuation() && i > 0 {
|
||||
cell_renderable = Box::new(InsetRenderable::new(
|
||||
cell_renderable,
|
||||
Insets::tlbr(1, 0, 0, 0),
|
||||
));
|
||||
}
|
||||
v.push(cell_renderable);
|
||||
v
|
||||
})
|
||||
.collect()
|
||||
let mut texts: Vec<Box<dyn Renderable>> = Vec::new();
|
||||
let mut first = true;
|
||||
for (idx, cell) in cells.iter().enumerate() {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
if !cell.is_stream_continuation() && !first {
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
let cell_lines = if Some(idx) == highlight_cell {
|
||||
cell.transcript_lines()
|
||||
.into_iter()
|
||||
.map(Stylize::reversed)
|
||||
.collect()
|
||||
} else {
|
||||
cell.transcript_lines()
|
||||
};
|
||||
lines.extend(cell_lines);
|
||||
texts.push(Box::new(CachedParagraph::new(
|
||||
Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }),
|
||||
)));
|
||||
first = false;
|
||||
}
|
||||
texts
|
||||
}
|
||||
|
||||
pub(crate) fn insert_cell(&mut self, cell: Arc<dyn HistoryCell>) {
|
||||
let follow_bottom = self.view.is_scrolled_to_bottom();
|
||||
// Append as a new Text chunk (with a separating blank if needed)
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
if !cell.is_stream_continuation() && !self.cells.is_empty() {
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
lines.extend(cell.transcript_lines());
|
||||
self.view.renderables.push(Box::new(CachedParagraph::new(
|
||||
Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }),
|
||||
)));
|
||||
self.cells.push(cell);
|
||||
self.view.renderables = Self::render_cells(&self.cells, self.highlight_cell);
|
||||
if follow_bottom {
|
||||
self.view.scroll_offset = usize::MAX;
|
||||
}
|
||||
@@ -437,7 +415,7 @@ impl TranscriptOverlay {
|
||||
|
||||
pub(crate) fn set_highlight_cell(&mut self, cell: Option<usize>) {
|
||||
self.highlight_cell = cell;
|
||||
self.view.renderables = Self::render_cells(&self.cells, self.highlight_cell);
|
||||
self.view.renderables = Self::render_cells_to_texts(&self.cells, self.highlight_cell);
|
||||
if let Some(idx) = self.highlight_cell {
|
||||
self.view.scroll_chunk_into_view(idx);
|
||||
}
|
||||
@@ -497,8 +475,8 @@ pub(crate) struct StaticOverlay {
|
||||
impl StaticOverlay {
|
||||
pub(crate) fn with_title(lines: Vec<Line<'static>>, title: String) -> Self {
|
||||
Self::with_renderables(
|
||||
vec![Box::new(CachedRenderable::new(Box::new(Paragraph::new(
|
||||
Text::from(lines),
|
||||
vec![Box::new(CachedParagraph::new(Paragraph::new(Text::from(
|
||||
lines,
|
||||
))))],
|
||||
title,
|
||||
)
|
||||
@@ -607,7 +585,7 @@ mod tests {
|
||||
self.lines.clone()
|
||||
}
|
||||
|
||||
fn transcript_lines(&self, _width: u16) -> Vec<Line<'static>> {
|
||||
fn transcript_lines(&self) -> Vec<Line<'static>> {
|
||||
self.lines.clone()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,146 +1,81 @@
|
||||
use ratatui::style::Style;
|
||||
use codex_core::bash::try_parse_bash;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use std::sync::OnceLock;
|
||||
use tree_sitter_highlight::Highlight;
|
||||
use tree_sitter_highlight::HighlightConfiguration;
|
||||
use tree_sitter_highlight::HighlightEvent;
|
||||
use tree_sitter_highlight::Highlighter;
|
||||
|
||||
// Ref: https://github.com/tree-sitter/tree-sitter-bash/blob/master/queries/highlights.scm
|
||||
#[derive(Copy, Clone)]
|
||||
enum BashHighlight {
|
||||
Comment,
|
||||
Constant,
|
||||
Embedded,
|
||||
Function,
|
||||
Keyword,
|
||||
Number,
|
||||
Operator,
|
||||
Property,
|
||||
String,
|
||||
}
|
||||
|
||||
impl BashHighlight {
|
||||
const ALL: [Self; 9] = [
|
||||
Self::Comment,
|
||||
Self::Constant,
|
||||
Self::Embedded,
|
||||
Self::Function,
|
||||
Self::Keyword,
|
||||
Self::Number,
|
||||
Self::Operator,
|
||||
Self::Property,
|
||||
Self::String,
|
||||
];
|
||||
|
||||
const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Comment => "comment",
|
||||
Self::Constant => "constant",
|
||||
Self::Embedded => "embedded",
|
||||
Self::Function => "function",
|
||||
Self::Keyword => "keyword",
|
||||
Self::Number => "number",
|
||||
Self::Operator => "operator",
|
||||
Self::Property => "property",
|
||||
Self::String => "string",
|
||||
}
|
||||
}
|
||||
|
||||
fn style(self) -> Style {
|
||||
match self {
|
||||
Self::Comment | Self::Operator | Self::String => Style::default().dim(),
|
||||
_ => Style::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static HIGHLIGHT_CONFIG: OnceLock<HighlightConfiguration> = OnceLock::new();
|
||||
|
||||
fn highlight_names() -> &'static [&'static str] {
|
||||
static NAMES: OnceLock<[&'static str; BashHighlight::ALL.len()]> = OnceLock::new();
|
||||
NAMES
|
||||
.get_or_init(|| BashHighlight::ALL.map(BashHighlight::as_str))
|
||||
.as_slice()
|
||||
}
|
||||
|
||||
fn highlight_config() -> &'static HighlightConfiguration {
|
||||
HIGHLIGHT_CONFIG.get_or_init(|| {
|
||||
let language = tree_sitter_bash::LANGUAGE.into();
|
||||
#[expect(clippy::expect_used)]
|
||||
let mut config = HighlightConfiguration::new(
|
||||
language,
|
||||
"bash",
|
||||
tree_sitter_bash::HIGHLIGHT_QUERY,
|
||||
"",
|
||||
"",
|
||||
)
|
||||
.expect("load bash highlight query");
|
||||
config.configure(highlight_names());
|
||||
config
|
||||
})
|
||||
}
|
||||
|
||||
fn highlight_for(highlight: Highlight) -> BashHighlight {
|
||||
BashHighlight::ALL[highlight.0]
|
||||
}
|
||||
|
||||
fn push_segment(lines: &mut Vec<Line<'static>>, segment: &str, style: Option<Style>) {
|
||||
for (i, part) in segment.split('\n').enumerate() {
|
||||
if i > 0 {
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
if part.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let span = match style {
|
||||
Some(style) => Span::styled(part.to_string(), style),
|
||||
None => part.to_string().into(),
|
||||
};
|
||||
if let Some(last) = lines.last_mut() {
|
||||
last.spans.push(span);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a bash script into per-line styled content using tree-sitter's
|
||||
/// bash highlight query. The highlighter is streamed so multi-line content is
|
||||
/// split into `Line`s while preserving style boundaries.
|
||||
/// Convert the full bash script into per-line styled content by first
|
||||
/// computing operator-dimmed spans across the entire script, then splitting
|
||||
/// by newlines and dimming heredoc body lines. Performs a single parse and
|
||||
/// reuses it for both highlighting and heredoc detection.
|
||||
pub(crate) fn highlight_bash_to_lines(script: &str) -> Vec<Line<'static>> {
|
||||
let mut highlighter = Highlighter::new();
|
||||
let iterator =
|
||||
match highlighter.highlight(highlight_config(), script.as_bytes(), None, |_| None) {
|
||||
Ok(iter) => iter,
|
||||
Err(_) => return vec![script.to_string().into()],
|
||||
};
|
||||
|
||||
let mut lines: Vec<Line<'static>> = vec![Line::from("")];
|
||||
let mut highlight_stack: Vec<Highlight> = Vec::new();
|
||||
|
||||
for event in iterator {
|
||||
match event {
|
||||
Ok(HighlightEvent::HighlightStart(highlight)) => highlight_stack.push(highlight),
|
||||
Ok(HighlightEvent::HighlightEnd) => {
|
||||
highlight_stack.pop();
|
||||
}
|
||||
Ok(HighlightEvent::Source { start, end }) => {
|
||||
if start == end {
|
||||
continue;
|
||||
// Parse once; use the tree for both highlighting and heredoc body detection.
|
||||
let spans: Vec<Span<'static>> = if let Some(tree) = try_parse_bash(script) {
|
||||
// Single walk: collect operator ranges and heredoc rows.
|
||||
let root = tree.root_node();
|
||||
let mut cursor = root.walk();
|
||||
let mut stack = vec![root];
|
||||
let mut ranges: Vec<(usize, usize)> = Vec::new();
|
||||
while let Some(node) = stack.pop() {
|
||||
if !node.is_named() && !node.is_extra() {
|
||||
let kind = node.kind();
|
||||
let is_quote = matches!(kind, "\"" | "'" | "`");
|
||||
let is_whitespace = kind.trim().is_empty();
|
||||
if !is_quote && !is_whitespace {
|
||||
ranges.push((node.start_byte(), node.end_byte()));
|
||||
}
|
||||
let style = highlight_stack.last().map(|h| highlight_for(*h).style());
|
||||
push_segment(&mut lines, &script[start..end], style);
|
||||
} else if node.kind() == "heredoc_body" {
|
||||
ranges.push((node.start_byte(), node.end_byte()));
|
||||
}
|
||||
for child in node.children(&mut cursor) {
|
||||
stack.push(child);
|
||||
}
|
||||
}
|
||||
if ranges.is_empty() {
|
||||
ranges.push((script.len(), script.len()));
|
||||
}
|
||||
ranges.sort_by_key(|(st, _)| *st);
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
let mut i = 0usize;
|
||||
for (start, end) in ranges.into_iter() {
|
||||
let dim_start = start.max(i);
|
||||
let dim_end = end;
|
||||
if dim_start < dim_end {
|
||||
if dim_start > i {
|
||||
spans.push(script[i..dim_start].to_string().into());
|
||||
}
|
||||
spans.push(script[dim_start..dim_end].to_string().dim());
|
||||
i = dim_end;
|
||||
}
|
||||
}
|
||||
if i < script.len() {
|
||||
spans.push(script[i..].to_string().into());
|
||||
}
|
||||
spans
|
||||
} else {
|
||||
vec![script.to_string().into()]
|
||||
};
|
||||
// Split spans into lines preserving style boundaries and highlights across newlines.
|
||||
let mut lines: Vec<Line<'static>> = vec![Line::from("")];
|
||||
for sp in spans {
|
||||
let style = sp.style;
|
||||
let text = sp.content.into_owned();
|
||||
for (i, part) in text.split('\n').enumerate() {
|
||||
if i > 0 {
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
if part.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let span = Span {
|
||||
style,
|
||||
content: std::borrow::Cow::Owned(part.to_string()),
|
||||
};
|
||||
if let Some(last) = lines.last_mut() {
|
||||
last.spans.push(span);
|
||||
}
|
||||
Err(_) => return vec![script.to_string().into()],
|
||||
}
|
||||
}
|
||||
|
||||
if lines.is_empty() {
|
||||
vec![Line::from("")]
|
||||
} else {
|
||||
lines
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -149,8 +84,11 @@ mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::style::Modifier;
|
||||
|
||||
fn reconstructed(lines: &[Line<'static>]) -> String {
|
||||
lines
|
||||
#[test]
|
||||
fn dims_expected_bash_operators() {
|
||||
let s = "echo foo && bar || baz | qux & (echo hi)";
|
||||
let lines = highlight_bash_to_lines(s);
|
||||
let reconstructed: String = lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
l.spans
|
||||
@@ -159,78 +97,49 @@ mod tests {
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
.join("\n");
|
||||
assert_eq!(reconstructed, s);
|
||||
|
||||
fn dimmed_tokens(lines: &[Line<'static>]) -> Vec<String> {
|
||||
lines
|
||||
fn is_dim(span: &Span<'_>) -> bool {
|
||||
span.style.add_modifier.contains(Modifier::DIM)
|
||||
}
|
||||
let dimmed: Vec<String> = lines
|
||||
.iter()
|
||||
.flat_map(|l| l.spans.iter())
|
||||
.filter(|sp| sp.style.add_modifier.contains(Modifier::DIM))
|
||||
.filter(|sp| is_dim(sp))
|
||||
.map(|sp| sp.content.clone().into_owned())
|
||||
.map(|token| token.trim().to_string())
|
||||
.filter(|token| !token.is_empty())
|
||||
.collect()
|
||||
.collect();
|
||||
assert_eq!(dimmed, vec!["&&", "||", "|", "&", "(", ")"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dims_expected_bash_operators() {
|
||||
let s = "echo foo && bar || baz | qux & (echo hi)";
|
||||
let lines = highlight_bash_to_lines(s);
|
||||
assert_eq!(reconstructed(&lines), s);
|
||||
|
||||
let dimmed = dimmed_tokens(&lines);
|
||||
assert!(dimmed.contains(&"&&".to_string()));
|
||||
assert!(dimmed.contains(&"|".to_string()));
|
||||
assert!(!dimmed.contains(&"echo".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dims_redirects_and_strings() {
|
||||
fn does_not_dim_quotes_but_dims_other_punct() {
|
||||
let s = "echo \"hi\" > out.txt; echo 'ok'";
|
||||
let lines = highlight_bash_to_lines(s);
|
||||
assert_eq!(reconstructed(&lines), s);
|
||||
let reconstructed: String = lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
l.spans
|
||||
.iter()
|
||||
.map(|sp| sp.content.clone())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert_eq!(reconstructed, s);
|
||||
|
||||
let dimmed = dimmed_tokens(&lines);
|
||||
fn is_dim(span: &Span<'_>) -> bool {
|
||||
span.style.add_modifier.contains(Modifier::DIM)
|
||||
}
|
||||
let dimmed: Vec<String> = lines
|
||||
.iter()
|
||||
.flat_map(|l| l.spans.iter())
|
||||
.filter(|sp| is_dim(sp))
|
||||
.map(|sp| sp.content.clone().into_owned())
|
||||
.collect();
|
||||
assert!(dimmed.contains(&">".to_string()));
|
||||
assert!(dimmed.contains(&"\"hi\"".to_string()));
|
||||
assert!(dimmed.contains(&"'ok'".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlights_command_and_strings() {
|
||||
let s = "echo \"hi\"";
|
||||
let lines = highlight_bash_to_lines(s);
|
||||
let mut echo_style = None;
|
||||
let mut string_style = None;
|
||||
for span in &lines[0].spans {
|
||||
let text = span.content.as_ref();
|
||||
if text == "echo" {
|
||||
echo_style = Some(span.style);
|
||||
}
|
||||
if text == "\"hi\"" {
|
||||
string_style = Some(span.style);
|
||||
}
|
||||
}
|
||||
let echo_style = echo_style.expect("echo span missing");
|
||||
let string_style = string_style.expect("string span missing");
|
||||
assert!(echo_style.fg.is_none());
|
||||
assert!(!echo_style.add_modifier.contains(Modifier::DIM));
|
||||
assert!(string_style.add_modifier.contains(Modifier::DIM));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlights_heredoc_body_as_string() {
|
||||
let s = "cat <<EOF\nheredoc body\nEOF";
|
||||
let lines = highlight_bash_to_lines(s);
|
||||
let body_line = &lines[1];
|
||||
let mut body_style = None;
|
||||
for span in &body_line.spans {
|
||||
if span.content.as_ref() == "heredoc body" {
|
||||
body_style = Some(span.style);
|
||||
}
|
||||
}
|
||||
let body_style = body_style.expect("missing heredoc span");
|
||||
assert!(body_style.add_modifier.contains(Modifier::DIM));
|
||||
assert!(dimmed.contains(&";".to_string()));
|
||||
assert!(!dimmed.contains(&"\"".to_string()));
|
||||
assert!(!dimmed.contains(&"'".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ pub mod highlight;
|
||||
pub mod line_utils;
|
||||
pub mod renderable;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Insets {
|
||||
pub left: u16,
|
||||
pub top: u16,
|
||||
@@ -38,13 +37,11 @@ pub trait RectExt {
|
||||
|
||||
impl RectExt for Rect {
|
||||
fn inset(&self, insets: Insets) -> Rect {
|
||||
let horizontal = insets.left.saturating_add(insets.right);
|
||||
let vertical = insets.top.saturating_add(insets.bottom);
|
||||
Rect {
|
||||
x: self.x.saturating_add(insets.left),
|
||||
y: self.y.saturating_add(insets.top),
|
||||
width: self.width.saturating_sub(horizontal),
|
||||
height: self.height.saturating_sub(vertical),
|
||||
x: self.x + insets.left,
|
||||
y: self.y + insets.top,
|
||||
width: self.width - insets.left - insets.right,
|
||||
height: self.height - insets.top - insets.bottom,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,14 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
|
||||
use crate::render::Insets;
|
||||
use crate::render::RectExt as _;
|
||||
|
||||
pub trait Renderable {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer);
|
||||
fn desired_height(&self, width: u16) -> u16;
|
||||
}
|
||||
|
||||
impl<R: Renderable + 'static> From<R> for Box<dyn Renderable> {
|
||||
fn from(value: R) -> Self {
|
||||
Box::new(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderable for () {
|
||||
fn render(&self, _area: Rect, _buf: &mut Buffer) {}
|
||||
fn desired_height(&self, _width: u16) -> u16 {
|
||||
@@ -79,15 +68,6 @@ impl<R: Renderable> Renderable for Option<R> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Renderable> Renderable for Arc<R> {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
self.as_ref().render(area, buf);
|
||||
}
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
self.as_ref().desired_height(width)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ColumnRenderable {
|
||||
children: Vec<Box<dyn Renderable>>,
|
||||
}
|
||||
@@ -120,29 +100,3 @@ impl ColumnRenderable {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct InsetRenderable {
|
||||
child: Box<dyn Renderable>,
|
||||
insets: Insets,
|
||||
}
|
||||
|
||||
impl Renderable for InsetRenderable {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
self.child.render(area.inset(self.insets), buf);
|
||||
}
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
self.child
|
||||
.desired_height(width - self.insets.left - self.insets.right)
|
||||
+ self.insets.top
|
||||
+ self.insets.bottom
|
||||
}
|
||||
}
|
||||
|
||||
impl InsetRenderable {
|
||||
pub fn new(child: impl Into<Box<dyn Renderable>>, insets: Insets) -> Self {
|
||||
Self {
|
||||
child: child.into(),
|
||||
insets,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) {
|
||||
"ts": now_ts(),
|
||||
"dir": "to_tui",
|
||||
"kind": "insert_history_cell",
|
||||
"lines": cell.transcript_lines(u16::MAX).len(),
|
||||
"lines": cell.transcript_lines().len(),
|
||||
});
|
||||
LOGGER.write_json_line(value);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ source: tui/src/diff_render.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
"• Added new_file.txt (+2 -0) "
|
||||
" 1 +alpha "
|
||||
" 2 +beta "
|
||||
" 1 +alpha "
|
||||
" 2 +beta "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
|
||||
@@ -3,9 +3,9 @@ source: tui/src/diff_render.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
"• Deleted tmp_delete_example.txt (+0 -3) "
|
||||
" 1 -first "
|
||||
" 2 -second "
|
||||
" 3 -third "
|
||||
" 1 -first "
|
||||
" 2 -second "
|
||||
" 3 -third "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
|
||||
@@ -4,11 +4,11 @@ expression: terminal.backend()
|
||||
---
|
||||
"• Edited 2 files (+2 -1) "
|
||||
" └ a.txt (+1 -1) "
|
||||
" 1 -one "
|
||||
" 1 +one changed "
|
||||
" 1 -one "
|
||||
" 1 +one changed "
|
||||
" "
|
||||
" └ b.txt (+1 -0) "
|
||||
" 1 +new "
|
||||
" 1 +new "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
---
|
||||
source: tui/src/diff_render.rs
|
||||
assertion_line: 748
|
||||
expression: terminal.backend()
|
||||
---
|
||||
"• Edited example.txt (+1 -1) "
|
||||
" 1 line one "
|
||||
" 2 -line two "
|
||||
" 2 +line two changed "
|
||||
" 3 line three "
|
||||
" 1 line one "
|
||||
" 2 -line two "
|
||||
" 2 +line two changed "
|
||||
" 3 line three "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
---
|
||||
source: tui/src/diff_render.rs
|
||||
expression: text
|
||||
---
|
||||
• Edited hundreds.txt (+1 -1)
|
||||
97 line 97
|
||||
98 line 98
|
||||
99 line 99
|
||||
100 -line 100
|
||||
100 +line 100 changed
|
||||
101 line 101
|
||||
102 line 102
|
||||
103 line 103
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
source: tui/src/diff_render.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
"• Edited example.txt (+1 -1) "
|
||||
" 1 line one "
|
||||
" 2 -line two "
|
||||
" 2 +line two changed "
|
||||
" 3 line three "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
@@ -1,11 +1,12 @@
|
||||
---
|
||||
source: tui/src/diff_render.rs
|
||||
assertion_line: 748
|
||||
expression: terminal.backend()
|
||||
---
|
||||
"• Edited abs_old.rs → abs_new.rs (+1 -1) "
|
||||
" 1 -X "
|
||||
" 1 +X changed "
|
||||
" 2 Y "
|
||||
" 1 -X "
|
||||
" 1 +X changed "
|
||||
" 2 Y "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
---
|
||||
source: tui/src/diff_render.rs
|
||||
assertion_line: 748
|
||||
expression: terminal.backend()
|
||||
---
|
||||
"• Edited long_example.txt (+1 -1) "
|
||||
" 1 line 1 "
|
||||
" 2 -short "
|
||||
" 2 +short this_is_a_very_long_modified_line_that_should_wrap_across_m "
|
||||
" ultiple_terminal_columns_and_continue_even_further_beyond_eighty_ "
|
||||
" columns_to_force_multiple_wraps "
|
||||
" 3 line 3 "
|
||||
" 1 line 1 "
|
||||
" 2 -short "
|
||||
" 2 +short this_is_a_very_long_modified_line_that_should_wrap_acro "
|
||||
" ss_multiple_terminal_columns_and_continue_even_further_beyond "
|
||||
" _eighty_columns_to_force_multiple_wraps "
|
||||
" 3 line 3 "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
|
||||
@@ -2,14 +2,15 @@
|
||||
source: tui/src/diff_render.rs
|
||||
expression: text
|
||||
---
|
||||
• Edited wrap_demo.txt (+2 -2)
|
||||
1 1
|
||||
2 -2
|
||||
2 +added long line which
|
||||
wraps and_if_there_i
|
||||
s_a_long_token_it_wil
|
||||
l_be_broken
|
||||
3 3
|
||||
4 -4
|
||||
4 +4 context line which
|
||||
also wraps across
|
||||
1 1
|
||||
2 -2
|
||||
2 +added long line w
|
||||
hich wraps and_if
|
||||
_there_is_a_long_
|
||||
token_it_will_be_
|
||||
broken
|
||||
3 3
|
||||
4 -4
|
||||
4 +4 context line wh
|
||||
ich also wraps ac
|
||||
ross
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
---
|
||||
source: tui/src/diff_render.rs
|
||||
assertion_line: 748
|
||||
expression: terminal.backend()
|
||||
---
|
||||
"• Edited old_name.rs → new_name.rs (+1 -1) "
|
||||
" 1 A "
|
||||
" 2 -B "
|
||||
" 2 +B changed "
|
||||
" 3 C "
|
||||
" 1 A "
|
||||
" 2 -B "
|
||||
" 2 +B changed "
|
||||
" 3 C "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
source: tui/src/diff_render.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
"1 +this is a very long line that should wrap across multiple terminal columns an "
|
||||
" d continue "
|
||||
" 1 +this is a very long line that should wrap across multiple terminal co "
|
||||
" lumns and continue "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
|
||||
@@ -4,12 +4,12 @@ expression: snapshot
|
||||
---
|
||||
/ T R A N S C R I P T / / / / / / / / / / / / / / / / / / / / / / / / / / / / /
|
||||
• Added foo.txt (+2 -0)
|
||||
1 +hello
|
||||
2 +world
|
||||
1 +hello
|
||||
2 +world
|
||||
|
||||
• Added foo.txt (+2 -0)
|
||||
1 +hello
|
||||
2 +world
|
||||
1 +hello
|
||||
2 +world
|
||||
─────────────────────────────────────────────────────────────────────────── 0% ─
|
||||
↑/↓ to scroll pgup/pgdn to page home/end to jump
|
||||
q to quit esc to edit prev
|
||||
|
||||
@@ -15,7 +15,6 @@ use ratatui::widgets::WidgetRef;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::exec_cell::spinner;
|
||||
use crate::key_hint;
|
||||
use crate::shimmer::shimmer_spans;
|
||||
use crate::tui::FrameRequester;
|
||||
@@ -164,11 +163,15 @@ impl WidgetRef for StatusIndicatorWidget {
|
||||
let now = Instant::now();
|
||||
let elapsed_duration = self.elapsed_duration_at(now);
|
||||
let pretty_elapsed = fmt_elapsed_compact(elapsed_duration.as_secs());
|
||||
let blink_on = (elapsed_duration.as_millis() / 600).is_multiple_of(2);
|
||||
|
||||
// Plain rendering: no borders or padding so the live cell is visually indistinguishable from terminal scrollback.
|
||||
let mut spans = Vec::with_capacity(5);
|
||||
spans.push(spinner(Some(self.last_resume_at)));
|
||||
spans.push(" ".into());
|
||||
if blink_on {
|
||||
spans.push("• ".into());
|
||||
} else {
|
||||
spans.push("◦ ".dim());
|
||||
}
|
||||
spans.extend(shimmer_spans(&self.header));
|
||||
spans.extend(vec![
|
||||
" ".into(),
|
||||
|
||||
@@ -90,7 +90,6 @@ mod tests {
|
||||
use super::*;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
async fn test_config() -> Config {
|
||||
let overrides = ConfigOverrides {
|
||||
@@ -196,7 +195,7 @@ mod tests {
|
||||
for d in deltas.iter() {
|
||||
ctrl.push(d);
|
||||
while let (Some(cell), idle) = ctrl.on_commit_tick() {
|
||||
lines.extend(cell.transcript_lines(u16::MAX));
|
||||
lines.extend(cell.transcript_lines());
|
||||
if idle {
|
||||
break;
|
||||
}
|
||||
@@ -204,14 +203,21 @@ mod tests {
|
||||
}
|
||||
// Finalize and flush remaining lines now.
|
||||
if let Some(cell) = ctrl.finalize() {
|
||||
lines.extend(cell.transcript_lines(u16::MAX));
|
||||
lines.extend(cell.transcript_lines());
|
||||
}
|
||||
|
||||
let streamed: Vec<_> = lines_to_plain_strings(&lines)
|
||||
.into_iter()
|
||||
// skip • and 2-space indentation
|
||||
.map(|s| s.chars().skip(2).collect::<String>())
|
||||
.collect();
|
||||
let mut flat = lines;
|
||||
// Drop leading blank and header line if present.
|
||||
if !flat.is_empty() && lines_to_plain_strings(&[flat[0].clone()])[0].is_empty() {
|
||||
flat.remove(0);
|
||||
}
|
||||
if !flat.is_empty() {
|
||||
let s0 = lines_to_plain_strings(&[flat[0].clone()])[0].clone();
|
||||
if s0 == "codex" {
|
||||
flat.remove(0);
|
||||
}
|
||||
}
|
||||
let streamed = lines_to_plain_strings(&flat);
|
||||
|
||||
// Full render of the same source
|
||||
let source: String = deltas.iter().copied().collect();
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
use crate::color::blend;
|
||||
use crate::color::is_light;
|
||||
use crate::color::perceptual_distance;
|
||||
use crate::terminal_palette::default_bg;
|
||||
use crate::terminal_palette::terminal_palette;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Style;
|
||||
|
||||
pub fn user_message_style() -> Style {
|
||||
user_message_style_for(default_bg())
|
||||
}
|
||||
|
||||
/// Returns the style for a user-authored message using the provided terminal background.
|
||||
pub fn user_message_style_for(terminal_bg: Option<(u8, u8, u8)>) -> Style {
|
||||
pub fn user_message_style(terminal_bg: Option<(u8, u8, u8)>) -> Style {
|
||||
match terminal_bg {
|
||||
Some(bg) => Style::default().bg(user_message_bg(bg)),
|
||||
None => Style::default(),
|
||||
|
||||
@@ -34,8 +34,6 @@ mod imp {
|
||||
use std::sync::Mutex;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
const TERMINAL_QUERIES_ENABLED: bool = false;
|
||||
|
||||
struct Cache<T> {
|
||||
attempted: bool,
|
||||
value: Option<T>,
|
||||
@@ -72,9 +70,6 @@ mod imp {
|
||||
}
|
||||
|
||||
pub(super) fn terminal_palette() -> Option<[(u8, u8, u8); 256]> {
|
||||
if !TERMINAL_QUERIES_ENABLED {
|
||||
return None;
|
||||
}
|
||||
static CACHE: OnceLock<Option<[(u8, u8, u8); 256]>> = OnceLock::new();
|
||||
*CACHE.get_or_init(|| match query_terminal_palette() {
|
||||
Ok(Some(palette)) => Some(palette),
|
||||
@@ -83,18 +78,12 @@ mod imp {
|
||||
}
|
||||
|
||||
pub(super) fn default_colors() -> Option<DefaultColors> {
|
||||
if !TERMINAL_QUERIES_ENABLED {
|
||||
return None;
|
||||
}
|
||||
let cache = default_colors_cache();
|
||||
let mut cache = cache.lock().ok()?;
|
||||
cache.get_or_init_with(|| query_default_colors().unwrap_or_default())
|
||||
}
|
||||
|
||||
pub(super) fn requery_default_colors() {
|
||||
if !TERMINAL_QUERIES_ENABLED {
|
||||
return;
|
||||
}
|
||||
if let Ok(mut cache) = default_colors_cache().lock() {
|
||||
cache.refresh_with(|| query_default_colors().unwrap_or_default());
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use std::ops::Range;
|
||||
use textwrap::Options;
|
||||
use textwrap::wrap_algorithms::Penalties;
|
||||
|
||||
use crate::render::line_utils::push_owned_lines;
|
||||
|
||||
@@ -91,11 +90,7 @@ impl<'a> RtOptions<'a> {
|
||||
subsequent_indent: Line::default(),
|
||||
break_words: true,
|
||||
word_separator: textwrap::WordSeparator::new(),
|
||||
wrap_algorithm: textwrap::WrapAlgorithm::OptimalFit(Penalties {
|
||||
// ~infinite overflow penalty, we never want to overflow a line.
|
||||
overflow_penalty: usize::MAX / 4,
|
||||
..Default::default()
|
||||
}),
|
||||
wrap_algorithm: textwrap::WrapAlgorithm::new(),
|
||||
word_splitter: textwrap::WordSplitter::HyphenSplitter,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,9 +23,6 @@ export type CodexExecArgs = {
|
||||
outputSchemaFile?: string;
|
||||
};
|
||||
|
||||
const INTERNAL_ORIGINATOR_ENV = "CODEX_INTERNAL_ORIGINATOR_OVERRIDE";
|
||||
const TYPESCRIPT_SDK_ORIGINATOR = "codex_sdk_ts";
|
||||
|
||||
export class CodexExec {
|
||||
private executablePath: string;
|
||||
constructor(executablePath: string | null = null) {
|
||||
@@ -62,9 +59,6 @@ export class CodexExec {
|
||||
const env = {
|
||||
...process.env,
|
||||
};
|
||||
if (!env[INTERNAL_ORIGINATOR_ENV]) {
|
||||
env[INTERNAL_ORIGINATOR_ENV] = TYPESCRIPT_SDK_ORIGINATOR;
|
||||
}
|
||||
if (args.baseUrl) {
|
||||
env.OPENAI_BASE_URL = args.baseUrl;
|
||||
}
|
||||
|
||||
@@ -54,7 +54,6 @@ export type ResponsesApiRequest = {
|
||||
export type RecordedRequest = {
|
||||
body: string;
|
||||
json: ResponsesApiRequest;
|
||||
headers: http.IncomingHttpHeaders;
|
||||
};
|
||||
|
||||
function formatSseEvent(event: SseEvent): string {
|
||||
@@ -91,7 +90,7 @@ export async function startResponsesTestProxy(
|
||||
if (req.method === "POST" && req.url === "/responses") {
|
||||
const body = await readRequestBody(req);
|
||||
const json = JSON.parse(body);
|
||||
requests.push({ body, json, headers: { ...req.headers } });
|
||||
requests.push({ body, json });
|
||||
|
||||
const status = options.statusCode ?? 200;
|
||||
res.statusCode = status;
|
||||
|
||||
@@ -345,30 +345,6 @@ describe("Codex", () => {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
it("sets the codex sdk originator header", async () => {
|
||||
const { url, close, requests } = await startResponsesTestProxy({
|
||||
statusCode: 200,
|
||||
responseBodies: [sse(responseStarted(), assistantMessage("Hi!"), responseCompleted())],
|
||||
});
|
||||
|
||||
try {
|
||||
const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" });
|
||||
|
||||
const thread = client.startThread();
|
||||
await thread.run("Hello, originator!");
|
||||
|
||||
expect(requests.length).toBeGreaterThan(0);
|
||||
const originatorHeader = requests[0]!.headers["originator"];
|
||||
if (Array.isArray(originatorHeader)) {
|
||||
expect(originatorHeader).toContain("codex_sdk_ts");
|
||||
} else {
|
||||
expect(originatorHeader).toBe("codex_sdk_ts");
|
||||
}
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
it("throws ThreadRunError on turn failures", async () => {
|
||||
const { url, close } = await startResponsesTestProxy({
|
||||
statusCode: 200,
|
||||
|
||||
Reference in New Issue
Block a user