mirror of
https://github.com/openai/codex.git
synced 2026-05-21 19:45:26 +00:00
Compare commits
65 Commits
rust-v0.65
...
jif/feed-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbbe3acdf4 | ||
|
|
98923654d0 | ||
|
|
57ba9fa100 | ||
|
|
acb8ed493f | ||
|
|
53a486f7ea | ||
|
|
3c3d3d1adc | ||
|
|
3c087e8fda | ||
|
|
7386e2efbc | ||
|
|
b2cb05d562 | ||
|
|
9a74228c66 | ||
|
|
315b1e957d | ||
|
|
82090803d9 | ||
|
|
f521d29726 | ||
|
|
93f61dbc5f | ||
|
|
6c9c563faf | ||
|
|
952d6c9465 | ||
|
|
2e4a402521 | ||
|
|
f48d88067e | ||
|
|
a8cbbdbc6e | ||
|
|
d08efb1743 | ||
|
|
5f80ad6da8 | ||
|
|
e91bb6b947 | ||
|
|
b8eab7ce90 | ||
|
|
b1c918d8f7 | ||
|
|
4c9762d15c | ||
|
|
7b359c9c8e | ||
|
|
6736d1828d | ||
|
|
073a8533b8 | ||
|
|
0972cd9404 | ||
|
|
28dcdb566a | ||
|
|
e8f6d65899 | ||
|
|
342c084cc3 | ||
|
|
903b7774bc | ||
|
|
6e6338aa87 | ||
|
|
7dfc3a4dc7 | ||
|
|
9b2055586d | ||
|
|
ce0b38c056 | ||
|
|
37c36024c7 | ||
|
|
291b54a762 | ||
|
|
2b5d0b2935 | ||
|
|
404a1ea34b | ||
|
|
36edb412b1 | ||
|
|
1b2509f05a | ||
|
|
f1b7cdc3bd | ||
|
|
c4e18f1b63 | ||
|
|
8f4e00e1f1 | ||
|
|
87666695ba | ||
|
|
871f44f385 | ||
|
|
3d35cb4619 | ||
|
|
e925a380dc | ||
|
|
ccdeb9d9c4 | ||
|
|
67e67e054f | ||
|
|
edd98dd3b7 | ||
|
|
3e6cd5660c | ||
|
|
cee37a32b2 | ||
|
|
8da91d1c89 | ||
|
|
00cc00ead8 | ||
|
|
70b97790be | ||
|
|
1cfc967eb8 | ||
|
|
9a50a04400 | ||
|
|
231ff19ca2 | ||
|
|
de08c735a6 | ||
|
|
3395ebd96e | ||
|
|
71504325d3 | ||
|
|
7f068cfbcc |
51
.github/workflows/rust-ci.yml
vendored
51
.github/workflows/rust-ci.yml
vendored
@@ -369,6 +369,57 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
# We have been running out of space when running this job on Linux for
|
||||
# x86_64-unknown-linux-gnu, so remove some unnecessary dependencies.
|
||||
- name: Remove unnecessary dependencies to save space
|
||||
if: ${{ startsWith(matrix.runner, 'ubuntu') }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo rm -rf \
|
||||
/usr/local/lib/android \
|
||||
/usr/share/dotnet \
|
||||
/usr/local/share/boost \
|
||||
/usr/local/lib/node_modules \
|
||||
/opt/ghc
|
||||
sudo apt-get remove -y docker.io docker-compose podman buildah
|
||||
|
||||
# Ensure brew includes this fix so that brew's shellenv.sh loads
|
||||
# cleanly in the Codex sandbox (it is frequently eval'd via .zprofile
|
||||
# for Brew users, including the macOS runners on GitHub):
|
||||
#
|
||||
# https://github.com/Homebrew/brew/pull/21157
|
||||
#
|
||||
# Once brew 5.0.5 is released and is the default on macOS runners, this
|
||||
# step can be removed.
|
||||
- name: Upgrade brew
|
||||
if: ${{ startsWith(matrix.runner, 'macos') }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
brew --version
|
||||
git -C "$(brew --repo)" fetch origin
|
||||
git -C "$(brew --repo)" checkout main
|
||||
git -C "$(brew --repo)" reset --hard origin/main
|
||||
export HOMEBREW_UPDATE_TO_TAG=0
|
||||
brew update
|
||||
brew upgrade
|
||||
brew --version
|
||||
|
||||
# Some integration tests rely on DotSlash being installed.
|
||||
# See https://github.com/openai/codex/pull/7617.
|
||||
- name: Install DotSlash
|
||||
uses: facebook/install-dotslash@v2
|
||||
|
||||
- name: Pre-fetch DotSlash artifacts
|
||||
# The Bash wrapper is not available on Windows.
|
||||
if: ${{ !startsWith(matrix.runner, 'windows') }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
dotslash -- fetch exec-server/tests/suite/bash
|
||||
|
||||
- uses: dtolnay/rust-toolchain@1.90
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
@@ -75,6 +75,7 @@ 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.
|
||||
- Prefer deep equals comparisons whenever possible. Perform `assert_eq!()` on entire objects, rather than individual fields.
|
||||
|
||||
### Integration tests (core)
|
||||
|
||||
|
||||
52
codex-rs/Cargo.lock
generated
52
codex-rs/Cargo.lock
generated
@@ -858,6 +858,7 @@ dependencies = [
|
||||
"http",
|
||||
"pretty_assertions",
|
||||
"regex-lite",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.17",
|
||||
@@ -865,6 +866,7 @@ dependencies = [
|
||||
"tokio-test",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"wiremock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1046,7 +1048,7 @@ dependencies = [
|
||||
"pretty_assertions",
|
||||
"regex-lite",
|
||||
"serde_json",
|
||||
"supports-color",
|
||||
"supports-color 3.0.2",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"toml",
|
||||
@@ -1086,10 +1088,13 @@ dependencies = [
|
||||
"codex-login",
|
||||
"codex-tui",
|
||||
"crossterm",
|
||||
"owo-colors",
|
||||
"pretty_assertions",
|
||||
"ratatui",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"supports-color 3.0.2",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tracing",
|
||||
@@ -1117,12 +1122,10 @@ name = "codex-common"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"codex-app-server-protocol",
|
||||
"codex-core",
|
||||
"codex-lmstudio",
|
||||
"codex-ollama",
|
||||
"codex-protocol",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"toml",
|
||||
]
|
||||
@@ -1237,7 +1240,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"shlex",
|
||||
"supports-color",
|
||||
"supports-color 3.0.2",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -1253,10 +1256,14 @@ name = "codex-exec-server"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
"async-trait",
|
||||
"clap",
|
||||
"codex-core",
|
||||
"codex-execpolicy",
|
||||
"exec_server_test_support",
|
||||
"libc",
|
||||
"maplit",
|
||||
"path-absolutize",
|
||||
"pretty_assertions",
|
||||
"rmcp",
|
||||
@@ -1269,6 +1276,7 @@ dependencies = [
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"which",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1610,7 +1618,7 @@ dependencies = [
|
||||
"shlex",
|
||||
"strum 0.27.2",
|
||||
"strum_macros 0.27.2",
|
||||
"supports-color",
|
||||
"supports-color 3.0.2",
|
||||
"tempfile",
|
||||
"textwrap 0.16.2",
|
||||
"tokio",
|
||||
@@ -2497,6 +2505,18 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exec_server_test_support"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
"codex-core",
|
||||
"rmcp",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "eyre"
|
||||
version = "0.6.12"
|
||||
@@ -2556,8 +2576,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "filedescriptor"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d"
|
||||
source = "git+https://github.com/pakrym/wezterm?branch=PSUEDOCONSOLE_INHERIT_CURSOR#fe38df8409545a696909aa9a09e63438630f217d"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"thiserror 1.0.69",
|
||||
@@ -4433,6 +4452,10 @@ name = "owo-colors"
|
||||
version = "4.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48dd4f4a2c8405440fd0462561f0e5806bd0f77e86f51c761481bdd4018b545e"
|
||||
dependencies = [
|
||||
"supports-color 2.1.0",
|
||||
"supports-color 3.0.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
@@ -4631,8 +4654,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "portable-pty"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e"
|
||||
source = "git+https://github.com/pakrym/wezterm?branch=PSUEDOCONSOLE_INHERIT_CURSOR#fe38df8409545a696909aa9a09e63438630f217d"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 1.3.2",
|
||||
@@ -4641,7 +4663,7 @@ dependencies = [
|
||||
"lazy_static",
|
||||
"libc",
|
||||
"log",
|
||||
"nix 0.28.0",
|
||||
"nix 0.29.0",
|
||||
"serial2",
|
||||
"shared_library",
|
||||
"shell-words",
|
||||
@@ -6169,6 +6191,16 @@ version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "supports-color"
|
||||
version = "2.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6398cde53adc3c4557306a96ce67b302968513830a77a95b2b17305d9719a89"
|
||||
dependencies = [
|
||||
"is-terminal",
|
||||
"is_ci",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "supports-color"
|
||||
version = "3.0.2"
|
||||
|
||||
@@ -47,7 +47,7 @@ members = [
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.65.0-alpha.5"
|
||||
version = "0.0.0"
|
||||
# Track the edition for all workspace crates in one place. Individual
|
||||
# crates can still override this value, but keeping it here means new
|
||||
# crates created with `cargo new -w ...` automatically inherit the 2024
|
||||
@@ -96,6 +96,7 @@ codex-utils-readiness = { path = "utils/readiness" }
|
||||
codex-utils-string = { path = "utils/string" }
|
||||
codex-windows-sandbox = { path = "windows-sandbox-rs" }
|
||||
core_test_support = { path = "core/tests/common" }
|
||||
exec_server_test_support = { path = "exec-server/tests/common" }
|
||||
mcp-types = { path = "mcp-types" }
|
||||
mcp_test_support = { path = "mcp-server/tests/common" }
|
||||
|
||||
@@ -178,8 +179,8 @@ seccompiler = "0.5.0"
|
||||
sentry = "0.34.0"
|
||||
serde = "1"
|
||||
serde_json = "1"
|
||||
serde_yaml = "0.9"
|
||||
serde_with = "3.16"
|
||||
serde_yaml = "0.9"
|
||||
serial_test = "3.2.0"
|
||||
sha1 = "0.10.6"
|
||||
sha2 = "0.10"
|
||||
@@ -288,6 +289,7 @@ opt-level = 0
|
||||
# Uncomment to debug local changes.
|
||||
# ratatui = { path = "../../ratatui" }
|
||||
crossterm = { git = "https://github.com/nornagon/crossterm", branch = "nornagon/color-query" }
|
||||
portable-pty = { git = "https://github.com/pakrym/wezterm", branch = "PSUEDOCONSOLE_INHERIT_CURSOR" }
|
||||
ratatui = { git = "https://github.com/nornagon/ratatui", branch = "nornagon-v0.29.0-patch" }
|
||||
|
||||
# Uncomment to debug local changes.
|
||||
|
||||
@@ -3,11 +3,11 @@ use std::path::PathBuf;
|
||||
|
||||
use codex_protocol::ConversationId;
|
||||
use codex_protocol::config_types::ForcedLoginMethod;
|
||||
use codex_protocol::config_types::ReasoningEffort;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::config_types::SandboxMode;
|
||||
use codex_protocol::config_types::Verbosity;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::parse_command::ParsedCommand;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
|
||||
@@ -4,11 +4,11 @@ use std::path::PathBuf;
|
||||
use crate::protocol::common::AuthMode;
|
||||
use codex_protocol::account::PlanType;
|
||||
use codex_protocol::approvals::SandboxCommandAssessment as CoreSandboxCommandAssessment;
|
||||
use codex_protocol::config_types::ReasoningEffort;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent;
|
||||
use codex_protocol::items::TurnItem as CoreTurnItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::parse_command::ParsedCommand as CoreParsedCommand;
|
||||
use codex_protocol::plan_tool::PlanItemArg as CorePlanItemArg;
|
||||
use codex_protocol::plan_tool::StepStatus as CorePlanStepStatus;
|
||||
@@ -209,6 +209,8 @@ pub struct OverriddenMetadata {
|
||||
pub struct ConfigWriteResponse {
|
||||
pub status: WriteStatus,
|
||||
pub version: String,
|
||||
/// Canonical path to the config file that was written.
|
||||
pub file_path: String,
|
||||
pub overridden_metadata: Option<OverriddenMetadata>,
|
||||
}
|
||||
|
||||
@@ -245,10 +247,11 @@ pub struct ConfigReadResponse {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ConfigValueWriteParams {
|
||||
pub file_path: String,
|
||||
pub key_path: String,
|
||||
pub value: JsonValue,
|
||||
pub merge_strategy: MergeStrategy,
|
||||
/// Path to the config file to write; defaults to the user's `config.toml` when omitted.
|
||||
pub file_path: Option<String>,
|
||||
pub expected_version: Option<String>,
|
||||
}
|
||||
|
||||
@@ -256,8 +259,9 @@ pub struct ConfigValueWriteParams {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ConfigBatchWriteParams {
|
||||
pub file_path: String,
|
||||
pub edits: Vec<ConfigEdit>,
|
||||
/// Path to the config file to write; defaults to the user's `config.toml` when omitted.
|
||||
pub file_path: Option<String>,
|
||||
pub expected_version: Option<String>,
|
||||
}
|
||||
|
||||
@@ -938,6 +942,9 @@ pub struct TurnError {
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ErrorNotification {
|
||||
pub error: TurnError,
|
||||
// Set to true if the error is transient and the app-server process will automatically retry.
|
||||
// If true, this will not interrupt a turn.
|
||||
pub will_retry: bool,
|
||||
pub thread_id: String,
|
||||
pub turn_id: String,
|
||||
}
|
||||
@@ -1137,6 +1144,9 @@ pub enum ThreadItem {
|
||||
arguments: JsonValue,
|
||||
result: Option<McpToolCallResult>,
|
||||
error: Option<McpToolCallError>,
|
||||
/// The duration of the MCP tool call in milliseconds.
|
||||
#[ts(type = "number | null")]
|
||||
duration_ms: Option<i64>,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
@@ -1294,6 +1304,7 @@ pub struct TurnDiffUpdatedNotification {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct TurnPlanUpdatedNotification {
|
||||
pub thread_id: String,
|
||||
pub turn_id: String,
|
||||
pub explanation: Option<String>,
|
||||
pub plan: Vec<TurnPlanStep>,
|
||||
@@ -1513,6 +1524,7 @@ pub struct RateLimitSnapshot {
|
||||
pub primary: Option<RateLimitWindow>,
|
||||
pub secondary: Option<RateLimitWindow>,
|
||||
pub credits: Option<CreditsSnapshot>,
|
||||
pub plan_type: Option<PlanType>,
|
||||
}
|
||||
|
||||
impl From<CoreRateLimitSnapshot> for RateLimitSnapshot {
|
||||
@@ -1521,6 +1533,7 @@ impl From<CoreRateLimitSnapshot> for RateLimitSnapshot {
|
||||
primary: value.primary.map(RateLimitWindow::from),
|
||||
secondary: value.secondary.map(RateLimitWindow::from),
|
||||
credits: value.credits.map(CreditsSnapshot::from),
|
||||
plan_type: value.plan_type,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,6 +179,7 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
cwd,
|
||||
reason,
|
||||
risk,
|
||||
proposed_execpolicy_amendment: _,
|
||||
parsed_cmd,
|
||||
}) => match api_version {
|
||||
ApiVersion::V1 => {
|
||||
@@ -332,6 +333,7 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::Error(ErrorNotification {
|
||||
error: turn_error,
|
||||
will_retry: false,
|
||||
thread_id: conversation_id.to_string(),
|
||||
turn_id: event_turn_id.clone(),
|
||||
}))
|
||||
@@ -347,6 +349,7 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::Error(ErrorNotification {
|
||||
error: turn_error,
|
||||
will_retry: true,
|
||||
thread_id: conversation_id.to_string(),
|
||||
turn_id: event_turn_id.clone(),
|
||||
}))
|
||||
@@ -661,6 +664,7 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
}
|
||||
EventMsg::PlanUpdate(plan_update_event) => {
|
||||
handle_turn_plan_update(
|
||||
conversation_id,
|
||||
&event_turn_id,
|
||||
plan_update_event,
|
||||
api_version,
|
||||
@@ -693,6 +697,7 @@ async fn handle_turn_diff(
|
||||
}
|
||||
|
||||
async fn handle_turn_plan_update(
|
||||
conversation_id: ConversationId,
|
||||
event_turn_id: &str,
|
||||
plan_update_event: UpdatePlanArgs,
|
||||
api_version: ApiVersion,
|
||||
@@ -700,6 +705,7 @@ async fn handle_turn_plan_update(
|
||||
) {
|
||||
if let ApiVersion::V2 = api_version {
|
||||
let notification = TurnPlanUpdatedNotification {
|
||||
thread_id: conversation_id.to_string(),
|
||||
turn_id: event_turn_id.to_string(),
|
||||
explanation: plan_update_event.explanation,
|
||||
plan: plan_update_event
|
||||
@@ -1174,6 +1180,7 @@ async fn construct_mcp_tool_call_notification(
|
||||
arguments: begin_event.invocation.arguments.unwrap_or(JsonValue::Null),
|
||||
result: None,
|
||||
error: None,
|
||||
duration_ms: None,
|
||||
};
|
||||
ItemStartedNotification {
|
||||
thread_id,
|
||||
@@ -1193,6 +1200,7 @@ async fn construct_mcp_tool_call_end_notification(
|
||||
} else {
|
||||
McpToolCallStatus::Failed
|
||||
};
|
||||
let duration_ms = i64::try_from(end_event.duration.as_millis()).ok();
|
||||
|
||||
let (result, error) = match &end_event.result {
|
||||
Ok(value) => (
|
||||
@@ -1218,6 +1226,7 @@ async fn construct_mcp_tool_call_end_notification(
|
||||
arguments: end_event.invocation.arguments.unwrap_or(JsonValue::Null),
|
||||
result,
|
||||
error,
|
||||
duration_ms,
|
||||
};
|
||||
ItemCompletedNotification {
|
||||
thread_id,
|
||||
@@ -1422,7 +1431,16 @@ mod tests {
|
||||
],
|
||||
};
|
||||
|
||||
handle_turn_plan_update("turn-123", update, ApiVersion::V2, &outgoing).await;
|
||||
let conversation_id = ConversationId::new();
|
||||
|
||||
handle_turn_plan_update(
|
||||
conversation_id,
|
||||
"turn-123",
|
||||
update,
|
||||
ApiVersion::V2,
|
||||
&outgoing,
|
||||
)
|
||||
.await;
|
||||
|
||||
let msg = rx
|
||||
.recv()
|
||||
@@ -1430,6 +1448,7 @@ mod tests {
|
||||
.ok_or_else(|| anyhow!("should send one notification"))?;
|
||||
match msg {
|
||||
OutgoingMessage::AppServerNotification(ServerNotification::TurnPlanUpdated(n)) => {
|
||||
assert_eq!(n.thread_id, conversation_id.to_string());
|
||||
assert_eq!(n.turn_id, "turn-123");
|
||||
assert_eq!(n.explanation.as_deref(), Some("need plan"));
|
||||
assert_eq!(n.plan.len(), 2);
|
||||
@@ -1480,6 +1499,7 @@ mod tests {
|
||||
unlimited: false,
|
||||
balance: Some("5".to_string()),
|
||||
}),
|
||||
plan_type: None,
|
||||
};
|
||||
|
||||
handle_token_count_event(
|
||||
@@ -1584,6 +1604,7 @@ mod tests {
|
||||
arguments: serde_json::json!({"server": ""}),
|
||||
result: None,
|
||||
error: None,
|
||||
duration_ms: None,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1737,6 +1758,7 @@ mod tests {
|
||||
arguments: JsonValue::Null,
|
||||
result: None,
|
||||
error: None,
|
||||
duration_ms: None,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1790,6 +1812,7 @@ mod tests {
|
||||
structured_content: None,
|
||||
}),
|
||||
error: None,
|
||||
duration_ms: Some(0),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1831,6 +1854,7 @@ mod tests {
|
||||
error: Some(McpToolCallError {
|
||||
message: "boom".to_string(),
|
||||
}),
|
||||
duration_ms: Some(1),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1862,8 +1862,7 @@ impl CodexMessageProcessor {
|
||||
|
||||
async fn list_models(&self, request_id: RequestId, params: ModelListParams) {
|
||||
let ModelListParams { limit, cursor } = params;
|
||||
let auth_mode = self.auth_manager.auth().map(|auth| auth.mode);
|
||||
let models = supported_models(auth_mode);
|
||||
let models = supported_models(self.conversation_manager.clone()).await;
|
||||
let total = models.len();
|
||||
|
||||
if total == 0 {
|
||||
|
||||
@@ -109,12 +109,17 @@ impl ConfigApi {
|
||||
|
||||
async fn apply_edits(
|
||||
&self,
|
||||
file_path: String,
|
||||
file_path: Option<String>,
|
||||
expected_version: Option<String>,
|
||||
edits: Vec<(String, JsonValue, MergeStrategy)>,
|
||||
) -> Result<ConfigWriteResponse, JSONRPCErrorError> {
|
||||
let allowed_path = self.codex_home.join(CONFIG_FILE_NAME);
|
||||
if !paths_match(&allowed_path, &file_path) {
|
||||
let provided_path = file_path
|
||||
.as_ref()
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| allowed_path.clone());
|
||||
|
||||
if !paths_match(&allowed_path, &provided_path) {
|
||||
return Err(config_write_error(
|
||||
ConfigWriteErrorCode::ConfigLayerReadonly,
|
||||
"Only writes to the user config are allowed",
|
||||
@@ -190,9 +195,16 @@ impl ConfigApi {
|
||||
.map(|_| WriteStatus::OkOverridden)
|
||||
.unwrap_or(WriteStatus::Ok);
|
||||
|
||||
let file_path = provided_path
|
||||
.canonicalize()
|
||||
.unwrap_or(provided_path.clone())
|
||||
.display()
|
||||
.to_string();
|
||||
|
||||
Ok(ConfigWriteResponse {
|
||||
status,
|
||||
version: updated_layers.user.version.clone(),
|
||||
file_path,
|
||||
overridden_metadata: overridden,
|
||||
})
|
||||
}
|
||||
@@ -587,15 +599,14 @@ fn canonical_json(value: &JsonValue) -> JsonValue {
|
||||
}
|
||||
}
|
||||
|
||||
fn paths_match(expected: &Path, provided: &str) -> bool {
|
||||
let provided_path = PathBuf::from(provided);
|
||||
fn paths_match(expected: &Path, provided: &Path) -> bool {
|
||||
if let (Ok(expanded_expected), Ok(expanded_provided)) =
|
||||
(expected.canonicalize(), provided_path.canonicalize())
|
||||
(expected.canonicalize(), provided.canonicalize())
|
||||
{
|
||||
return expanded_expected == expanded_provided;
|
||||
}
|
||||
|
||||
expected == provided_path
|
||||
expected == provided
|
||||
}
|
||||
|
||||
fn value_at_path<'a>(root: &'a TomlValue, segments: &[String]) -> Option<&'a TomlValue> {
|
||||
@@ -795,7 +806,7 @@ mod tests {
|
||||
|
||||
let result = api
|
||||
.write_value(ConfigValueWriteParams {
|
||||
file_path: tmp.path().join(CONFIG_FILE_NAME).display().to_string(),
|
||||
file_path: Some(tmp.path().join(CONFIG_FILE_NAME).display().to_string()),
|
||||
key_path: "approval_policy".to_string(),
|
||||
value: json!("never"),
|
||||
merge_strategy: MergeStrategy::Replace,
|
||||
@@ -832,7 +843,7 @@ mod tests {
|
||||
let api = ConfigApi::new(tmp.path().to_path_buf(), vec![]);
|
||||
let error = api
|
||||
.write_value(ConfigValueWriteParams {
|
||||
file_path: tmp.path().join(CONFIG_FILE_NAME).display().to_string(),
|
||||
file_path: Some(tmp.path().join(CONFIG_FILE_NAME).display().to_string()),
|
||||
key_path: "model".to_string(),
|
||||
value: json!("gpt-5"),
|
||||
merge_strategy: MergeStrategy::Replace,
|
||||
@@ -852,6 +863,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_value_defaults_to_user_config_path() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
std::fs::write(tmp.path().join(CONFIG_FILE_NAME), "").unwrap();
|
||||
|
||||
let api = ConfigApi::new(tmp.path().to_path_buf(), vec![]);
|
||||
api.write_value(ConfigValueWriteParams {
|
||||
file_path: None,
|
||||
key_path: "model".to_string(),
|
||||
value: json!("gpt-new"),
|
||||
merge_strategy: MergeStrategy::Replace,
|
||||
expected_version: None,
|
||||
})
|
||||
.await
|
||||
.expect("write succeeds");
|
||||
|
||||
let contents =
|
||||
std::fs::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).expect("read config");
|
||||
assert!(
|
||||
contents.contains("model = \"gpt-new\""),
|
||||
"config.toml should be updated even when file_path is omitted"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_user_value_rejected_even_if_overridden_by_managed() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
@@ -872,7 +907,7 @@ mod tests {
|
||||
|
||||
let error = api
|
||||
.write_value(ConfigValueWriteParams {
|
||||
file_path: tmp.path().join(CONFIG_FILE_NAME).display().to_string(),
|
||||
file_path: Some(tmp.path().join(CONFIG_FILE_NAME).display().to_string()),
|
||||
key_path: "approval_policy".to_string(),
|
||||
value: json!("bogus"),
|
||||
merge_strategy: MergeStrategy::Replace,
|
||||
@@ -957,7 +992,7 @@ mod tests {
|
||||
|
||||
let result = api
|
||||
.write_value(ConfigValueWriteParams {
|
||||
file_path: tmp.path().join(CONFIG_FILE_NAME).display().to_string(),
|
||||
file_path: Some(tmp.path().join(CONFIG_FILE_NAME).display().to_string()),
|
||||
key_path: "approval_policy".to_string(),
|
||||
value: json!("on-request"),
|
||||
merge_strategy: MergeStrategy::Replace,
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_app_server_protocol::Model;
|
||||
use codex_app_server_protocol::ReasoningEffortOption;
|
||||
use codex_common::model_presets::ModelPreset;
|
||||
use codex_common::model_presets::ReasoningEffortPreset;
|
||||
use codex_common::model_presets::builtin_model_presets;
|
||||
use codex_core::ConversationManager;
|
||||
use codex_protocol::openai_models::ModelPreset;
|
||||
use codex_protocol::openai_models::ReasoningEffortPreset;
|
||||
|
||||
pub fn supported_models(auth_mode: Option<AuthMode>) -> Vec<Model> {
|
||||
builtin_model_presets(auth_mode)
|
||||
pub async fn supported_models(conversation_manager: Arc<ConversationManager>) -> Vec<Model> {
|
||||
conversation_manager
|
||||
.list_models()
|
||||
.await
|
||||
.into_iter()
|
||||
.map(model_from_preset)
|
||||
.collect()
|
||||
@@ -27,7 +30,7 @@ fn model_from_preset(preset: ModelPreset) -> Model {
|
||||
}
|
||||
|
||||
fn reasoning_efforts_from_preset(
|
||||
efforts: &'static [ReasoningEffortPreset],
|
||||
efforts: Vec<ReasoningEffortPreset>,
|
||||
) -> Vec<ReasoningEffortOption> {
|
||||
efforts
|
||||
.iter()
|
||||
|
||||
@@ -16,6 +16,9 @@ use tracing::warn;
|
||||
|
||||
use crate::error_code::INTERNAL_ERROR_CODE;
|
||||
|
||||
#[cfg(test)]
|
||||
use codex_protocol::account::PlanType;
|
||||
|
||||
/// Sends messages to the client and manages request callbacks.
|
||||
pub(crate) struct OutgoingMessageSender {
|
||||
next_request_id: AtomicI64,
|
||||
@@ -230,6 +233,7 @@ mod tests {
|
||||
}),
|
||||
secondary: None,
|
||||
credits: None,
|
||||
plan_type: Some(PlanType::Plus),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -245,7 +249,8 @@ mod tests {
|
||||
"resetsAt": 123
|
||||
},
|
||||
"secondary": null,
|
||||
"credits": null
|
||||
"credits": null,
|
||||
"planType": "plus"
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -23,10 +23,10 @@ use codex_app_server_protocol::SendUserTurnResponse;
|
||||
use codex_app_server_protocol::ServerRequest;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use codex_core::protocol_config_types::ReasoningEffort;
|
||||
use codex_core::protocol_config_types::ReasoningSummary;
|
||||
use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
|
||||
use codex_protocol::config_types::SandboxMode;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::parse_command::ParsedCommand;
|
||||
use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
|
||||
@@ -10,10 +10,10 @@ use codex_app_server_protocol::Tools;
|
||||
use codex_app_server_protocol::UserSavedConfig;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_protocol::config_types::ForcedLoginMethod;
|
||||
use codex_protocol::config_types::ReasoningEffort;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::config_types::SandboxMode;
|
||||
use codex_protocol::config_types::Verbosity;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -206,7 +206,7 @@ model = "gpt-old"
|
||||
|
||||
let write_id = mcp
|
||||
.send_config_value_write_request(ConfigValueWriteParams {
|
||||
file_path: codex_home.path().join("config.toml").display().to_string(),
|
||||
file_path: None,
|
||||
key_path: "model".to_string(),
|
||||
value: json!("gpt-new"),
|
||||
merge_strategy: MergeStrategy::Replace,
|
||||
@@ -219,8 +219,16 @@ model = "gpt-old"
|
||||
)
|
||||
.await??;
|
||||
let write: ConfigWriteResponse = to_response(write_resp)?;
|
||||
let expected_file_path = codex_home
|
||||
.path()
|
||||
.join("config.toml")
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.display()
|
||||
.to_string();
|
||||
|
||||
assert_eq!(write.status, WriteStatus::Ok);
|
||||
assert_eq!(write.file_path, expected_file_path);
|
||||
assert!(write.overridden_metadata.is_none());
|
||||
|
||||
let verify_id = mcp
|
||||
@@ -254,7 +262,7 @@ model = "gpt-old"
|
||||
|
||||
let write_id = mcp
|
||||
.send_config_value_write_request(ConfigValueWriteParams {
|
||||
file_path: codex_home.path().join("config.toml").display().to_string(),
|
||||
file_path: Some(codex_home.path().join("config.toml").display().to_string()),
|
||||
key_path: "model".to_string(),
|
||||
value: json!("gpt-new"),
|
||||
merge_strategy: MergeStrategy::Replace,
|
||||
@@ -288,7 +296,7 @@ async fn config_batch_write_applies_multiple_edits() -> Result<()> {
|
||||
|
||||
let batch_id = mcp
|
||||
.send_config_batch_write_request(ConfigBatchWriteParams {
|
||||
file_path: codex_home.path().join("config.toml").display().to_string(),
|
||||
file_path: Some(codex_home.path().join("config.toml").display().to_string()),
|
||||
edits: vec![
|
||||
ConfigEdit {
|
||||
key_path: "sandbox_mode".to_string(),
|
||||
@@ -314,6 +322,14 @@ async fn config_batch_write_applies_multiple_edits() -> Result<()> {
|
||||
.await??;
|
||||
let batch_write: ConfigWriteResponse = to_response(batch_resp)?;
|
||||
assert_eq!(batch_write.status, WriteStatus::Ok);
|
||||
let expected_file_path = codex_home
|
||||
.path()
|
||||
.join("config.toml")
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.display()
|
||||
.to_string();
|
||||
assert_eq!(batch_write.file_path, expected_file_path);
|
||||
|
||||
let read_id = mcp
|
||||
.send_config_read_request(ConfigReadParams {
|
||||
|
||||
@@ -11,7 +11,7 @@ use codex_app_server_protocol::ModelListParams;
|
||||
use codex_app_server_protocol::ModelListResponse;
|
||||
use codex_app_server_protocol::ReasoningEffortOption;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_protocol::config_types::ReasoningEffort;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
|
||||
@@ -11,6 +11,7 @@ use codex_app_server_protocol::RateLimitSnapshot;
|
||||
use codex_app_server_protocol::RateLimitWindow;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_core::auth::AuthCredentialsStoreMode;
|
||||
use codex_protocol::account::PlanType as AccountPlanType;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::path::Path;
|
||||
@@ -153,6 +154,7 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
|
||||
resets_at: Some(secondary_reset_timestamp),
|
||||
}),
|
||||
credits: None,
|
||||
plan_type: Some(AccountPlanType::Pro),
|
||||
},
|
||||
};
|
||||
assert_eq!(received, expected);
|
||||
|
||||
@@ -30,8 +30,8 @@ use codex_app_server_protocol::TurnStartResponse;
|
||||
use codex_app_server_protocol::TurnStartedNotification;
|
||||
use codex_app_server_protocol::TurnStatus;
|
||||
use codex_app_server_protocol::UserInput as V2UserInput;
|
||||
use codex_core::protocol_config_types::ReasoningEffort;
|
||||
use codex_core::protocol_config_types::ReasoningSummary;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -699,13 +699,7 @@ fn derive_new_contents_from_chunks(
|
||||
}
|
||||
};
|
||||
|
||||
let mut original_lines: Vec<String> = original_contents.split('\n').map(String::from).collect();
|
||||
|
||||
// Drop the trailing empty element that results from the final newline so
|
||||
// that line counts match the behaviour of standard `diff`.
|
||||
if original_lines.last().is_some_and(String::is_empty) {
|
||||
original_lines.pop();
|
||||
}
|
||||
let original_lines: Vec<String> = build_lines_from_contents(&original_contents);
|
||||
|
||||
let replacements = compute_replacements(&original_lines, path, chunks)?;
|
||||
let new_lines = apply_replacements(original_lines, &replacements);
|
||||
@@ -713,13 +707,67 @@ fn derive_new_contents_from_chunks(
|
||||
if !new_lines.last().is_some_and(String::is_empty) {
|
||||
new_lines.push(String::new());
|
||||
}
|
||||
let new_contents = new_lines.join("\n");
|
||||
let new_contents = build_contents_from_lines(&original_contents, &new_lines);
|
||||
Ok(AppliedPatch {
|
||||
original_contents,
|
||||
new_contents,
|
||||
})
|
||||
}
|
||||
|
||||
// TODO(dylan-hurd-oai): I think we can migrate to just use `contents.lines()`
|
||||
// across all platforms.
|
||||
fn build_lines_from_contents(contents: &str) -> Vec<String> {
|
||||
if cfg!(windows) {
|
||||
contents.lines().map(String::from).collect()
|
||||
} else {
|
||||
let mut lines: Vec<String> = contents.split('\n').map(String::from).collect();
|
||||
|
||||
// Drop the trailing empty element that results from the final newline so
|
||||
// that line counts match the behaviour of standard `diff`.
|
||||
if lines.last().is_some_and(String::is_empty) {
|
||||
lines.pop();
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
fn build_contents_from_lines(original_contents: &str, lines: &[String]) -> String {
|
||||
if cfg!(windows) {
|
||||
// for now, only compute this if we're on Windows.
|
||||
let uses_crlf = contents_uses_crlf(original_contents);
|
||||
if uses_crlf {
|
||||
lines.join("\r\n")
|
||||
} else {
|
||||
lines.join("\n")
|
||||
}
|
||||
} else {
|
||||
lines.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
/// Detects whether the source file uses Windows CRLF line endings consistently.
|
||||
/// We only consider a file CRLF-formatted if every newline is part of a
|
||||
/// CRLF sequence. This avoids rewriting an LF-formatted file that merely
|
||||
/// contains embedded sequences of "\r\n".
|
||||
///
|
||||
/// Returns `true` if the file uses CRLF line endings, `false` otherwise.
|
||||
fn contents_uses_crlf(contents: &str) -> bool {
|
||||
let bytes = contents.as_bytes();
|
||||
let mut n_newlines = 0usize;
|
||||
let mut n_crlf = 0usize;
|
||||
for i in 0..bytes.len() {
|
||||
if bytes[i] == b'\n' {
|
||||
n_newlines += 1;
|
||||
if i > 0 && bytes[i - 1] == b'\r' {
|
||||
n_crlf += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
n_newlines > 0 && n_crlf == n_newlines
|
||||
}
|
||||
|
||||
/// Compute a list of replacements needed to transform `original_lines` into the
|
||||
/// new lines, given the patch `chunks`. Each replacement is returned as
|
||||
/// `(start_index, old_len, new_lines)`.
|
||||
@@ -1359,6 +1407,72 @@ PATCH"#,
|
||||
assert_eq!(contents, "a\nB\nc\nd\nE\nf\ng\n");
|
||||
}
|
||||
|
||||
/// Ensure CRLF line endings are preserved for updated files on Windows‑style inputs.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn test_preserve_crlf_line_endings_on_update() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("crlf.txt");
|
||||
|
||||
// Original file uses CRLF (\r\n) endings.
|
||||
std::fs::write(&path, b"a\r\nb\r\nc\r\n").unwrap();
|
||||
|
||||
// Replace `b` -> `B` and append `d`.
|
||||
let patch = wrap_patch(&format!(
|
||||
r#"*** Update File: {}
|
||||
@@
|
||||
a
|
||||
-b
|
||||
+B
|
||||
@@
|
||||
c
|
||||
+d
|
||||
*** End of File"#,
|
||||
path.display()
|
||||
));
|
||||
|
||||
let mut stdout = Vec::new();
|
||||
let mut stderr = Vec::new();
|
||||
apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
|
||||
|
||||
let out = std::fs::read(&path).unwrap();
|
||||
// Expect all CRLF endings; count occurrences of CRLF and ensure there are 4 lines.
|
||||
let content = String::from_utf8_lossy(&out);
|
||||
assert!(content.contains("\r\n"));
|
||||
// No bare LF occurrences immediately preceding a non-CR: the text should not contain "a\nb".
|
||||
assert!(!content.contains("a\nb"));
|
||||
// Validate exact content sequence with CRLF delimiters.
|
||||
assert_eq!(content, "a\r\nB\r\nc\r\nd\r\n");
|
||||
}
|
||||
|
||||
/// Ensure CRLF inputs with embedded carriage returns in the content are preserved.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn test_preserve_crlf_embedded_carriage_returns_on_append() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("crlf_cr_content.txt");
|
||||
|
||||
// Original file: first line has a literal '\r' in the content before the CRLF terminator.
|
||||
std::fs::write(&path, b"foo\r\r\nbar\r\n").unwrap();
|
||||
|
||||
// Append a new line without modifying existing ones.
|
||||
let patch = wrap_patch(&format!(
|
||||
r#"*** Update File: {}
|
||||
@@
|
||||
+BAZ
|
||||
*** End of File"#,
|
||||
path.display()
|
||||
));
|
||||
|
||||
let mut stdout = Vec::new();
|
||||
let mut stderr = Vec::new();
|
||||
apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
|
||||
|
||||
let out = std::fs::read(&path).unwrap();
|
||||
// CRLF endings must be preserved and the extra CR in "foo\r\r" must not be collapsed.
|
||||
assert_eq!(out.as_slice(), b"foo\r\r\nbar\r\nBAZ\r\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pure_addition_chunk_followed_by_removal() {
|
||||
let dir = tempdir().unwrap();
|
||||
@@ -1544,6 +1658,37 @@ PATCH"#,
|
||||
assert_eq!(expected, diff);
|
||||
}
|
||||
|
||||
/// For LF-only inputs with a trailing newline ensure that the helper used
|
||||
/// on Windows-style builds drops the synthetic trailing empty element so
|
||||
/// replacements behave like standard `diff` line numbering.
|
||||
#[test]
|
||||
fn test_derive_new_contents_lf_trailing_newline() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("lf_trailing_newline.txt");
|
||||
fs::write(&path, "foo\nbar\n").unwrap();
|
||||
|
||||
let patch = wrap_patch(&format!(
|
||||
r#"*** Update File: {}
|
||||
@@
|
||||
foo
|
||||
-bar
|
||||
+BAR
|
||||
"#,
|
||||
path.display()
|
||||
));
|
||||
|
||||
let patch = parse_patch(&patch).unwrap();
|
||||
let chunks = match patch.hunks.as_slice() {
|
||||
[Hunk::UpdateFile { chunks, .. }] => chunks,
|
||||
_ => panic!("Expected a single UpdateFile hunk"),
|
||||
};
|
||||
|
||||
let AppliedPatch { new_contents, .. } =
|
||||
derive_new_contents_from_chunks(&path, chunks).unwrap();
|
||||
|
||||
assert_eq!(new_contents, "foo\nBAR\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unified_diff_insert_at_eof() {
|
||||
// Insert a new line at end‑of‑file.
|
||||
|
||||
1
codex-rs/apply-patch/tests/fixtures/scenarios/.gitattributes
vendored
Normal file
1
codex-rs/apply-patch/tests/fixtures/scenarios/.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
** text eol=lf
|
||||
1
codex-rs/apply-patch/tests/fixtures/scenarios/001_add_file/expected/bar.md
vendored
Normal file
1
codex-rs/apply-patch/tests/fixtures/scenarios/001_add_file/expected/bar.md
vendored
Normal file
@@ -0,0 +1 @@
|
||||
This is a new file
|
||||
4
codex-rs/apply-patch/tests/fixtures/scenarios/001_add_file/patch.txt
vendored
Normal file
4
codex-rs/apply-patch/tests/fixtures/scenarios/001_add_file/patch.txt
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
*** Begin Patch
|
||||
*** Add File: bar.md
|
||||
+This is a new file
|
||||
*** End Patch
|
||||
2
codex-rs/apply-patch/tests/fixtures/scenarios/002_multiple_operations/expected/modify.txt
vendored
Normal file
2
codex-rs/apply-patch/tests/fixtures/scenarios/002_multiple_operations/expected/modify.txt
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
line1
|
||||
changed
|
||||
@@ -0,0 +1 @@
|
||||
created
|
||||
1
codex-rs/apply-patch/tests/fixtures/scenarios/002_multiple_operations/input/delete.txt
vendored
Normal file
1
codex-rs/apply-patch/tests/fixtures/scenarios/002_multiple_operations/input/delete.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
obsolete
|
||||
2
codex-rs/apply-patch/tests/fixtures/scenarios/002_multiple_operations/input/modify.txt
vendored
Normal file
2
codex-rs/apply-patch/tests/fixtures/scenarios/002_multiple_operations/input/modify.txt
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
line1
|
||||
line2
|
||||
9
codex-rs/apply-patch/tests/fixtures/scenarios/002_multiple_operations/patch.txt
vendored
Normal file
9
codex-rs/apply-patch/tests/fixtures/scenarios/002_multiple_operations/patch.txt
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
*** Begin Patch
|
||||
*** Add File: nested/new.txt
|
||||
+created
|
||||
*** Delete File: delete.txt
|
||||
*** Update File: modify.txt
|
||||
@@
|
||||
-line2
|
||||
+changed
|
||||
*** End Patch
|
||||
4
codex-rs/apply-patch/tests/fixtures/scenarios/003_multiple_chunks/expected/multi.txt
vendored
Normal file
4
codex-rs/apply-patch/tests/fixtures/scenarios/003_multiple_chunks/expected/multi.txt
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
line1
|
||||
changed2
|
||||
line3
|
||||
changed4
|
||||
4
codex-rs/apply-patch/tests/fixtures/scenarios/003_multiple_chunks/input/multi.txt
vendored
Normal file
4
codex-rs/apply-patch/tests/fixtures/scenarios/003_multiple_chunks/input/multi.txt
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
line1
|
||||
line2
|
||||
line3
|
||||
line4
|
||||
9
codex-rs/apply-patch/tests/fixtures/scenarios/003_multiple_chunks/patch.txt
vendored
Normal file
9
codex-rs/apply-patch/tests/fixtures/scenarios/003_multiple_chunks/patch.txt
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
*** Begin Patch
|
||||
*** Update File: multi.txt
|
||||
@@
|
||||
-line2
|
||||
+changed2
|
||||
@@
|
||||
-line4
|
||||
+changed4
|
||||
*** End Patch
|
||||
@@ -0,0 +1 @@
|
||||
unrelated file
|
||||
@@ -0,0 +1 @@
|
||||
new content
|
||||
1
codex-rs/apply-patch/tests/fixtures/scenarios/004_move_to_new_directory/input/old/name.txt
vendored
Normal file
1
codex-rs/apply-patch/tests/fixtures/scenarios/004_move_to_new_directory/input/old/name.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
old content
|
||||
1
codex-rs/apply-patch/tests/fixtures/scenarios/004_move_to_new_directory/input/old/other.txt
vendored
Normal file
1
codex-rs/apply-patch/tests/fixtures/scenarios/004_move_to_new_directory/input/old/other.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
unrelated file
|
||||
7
codex-rs/apply-patch/tests/fixtures/scenarios/004_move_to_new_directory/patch.txt
vendored
Normal file
7
codex-rs/apply-patch/tests/fixtures/scenarios/004_move_to_new_directory/patch.txt
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
*** Begin Patch
|
||||
*** Update File: old/name.txt
|
||||
*** Move to: renamed/dir/name.txt
|
||||
@@
|
||||
-old content
|
||||
+new content
|
||||
*** End Patch
|
||||
2
codex-rs/apply-patch/tests/fixtures/scenarios/005_rejects_empty_patch/patch.txt
vendored
Normal file
2
codex-rs/apply-patch/tests/fixtures/scenarios/005_rejects_empty_patch/patch.txt
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*** Begin Patch
|
||||
*** End Patch
|
||||
@@ -0,0 +1,2 @@
|
||||
line1
|
||||
line2
|
||||
2
codex-rs/apply-patch/tests/fixtures/scenarios/006_rejects_missing_context/input/modify.txt
vendored
Normal file
2
codex-rs/apply-patch/tests/fixtures/scenarios/006_rejects_missing_context/input/modify.txt
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
line1
|
||||
line2
|
||||
6
codex-rs/apply-patch/tests/fixtures/scenarios/006_rejects_missing_context/patch.txt
vendored
Normal file
6
codex-rs/apply-patch/tests/fixtures/scenarios/006_rejects_missing_context/patch.txt
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
*** Begin Patch
|
||||
*** Update File: modify.txt
|
||||
@@
|
||||
-missing
|
||||
+changed
|
||||
*** End Patch
|
||||
3
codex-rs/apply-patch/tests/fixtures/scenarios/007_rejects_missing_file_delete/patch.txt
vendored
Normal file
3
codex-rs/apply-patch/tests/fixtures/scenarios/007_rejects_missing_file_delete/patch.txt
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
*** Begin Patch
|
||||
*** Delete File: missing.txt
|
||||
*** End Patch
|
||||
3
codex-rs/apply-patch/tests/fixtures/scenarios/008_rejects_empty_update_hunk/patch.txt
vendored
Normal file
3
codex-rs/apply-patch/tests/fixtures/scenarios/008_rejects_empty_update_hunk/patch.txt
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
*** Begin Patch
|
||||
*** Update File: foo.txt
|
||||
*** End Patch
|
||||
@@ -0,0 +1,6 @@
|
||||
*** Begin Patch
|
||||
*** Update File: missing.txt
|
||||
@@
|
||||
-old
|
||||
+new
|
||||
*** End Patch
|
||||
@@ -0,0 +1 @@
|
||||
unrelated file
|
||||
@@ -0,0 +1 @@
|
||||
new
|
||||
@@ -0,0 +1 @@
|
||||
from
|
||||
@@ -0,0 +1 @@
|
||||
unrelated file
|
||||
@@ -0,0 +1 @@
|
||||
existing
|
||||
@@ -0,0 +1,7 @@
|
||||
*** Begin Patch
|
||||
*** Update File: old/name.txt
|
||||
*** Move to: renamed/dir/name.txt
|
||||
@@
|
||||
-from
|
||||
+new
|
||||
*** End Patch
|
||||
@@ -0,0 +1 @@
|
||||
new content
|
||||
@@ -0,0 +1 @@
|
||||
old content
|
||||
4
codex-rs/apply-patch/tests/fixtures/scenarios/011_add_overwrites_existing_file/patch.txt
vendored
Normal file
4
codex-rs/apply-patch/tests/fixtures/scenarios/011_add_overwrites_existing_file/patch.txt
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
*** Begin Patch
|
||||
*** Add File: duplicate.txt
|
||||
+new content
|
||||
*** End Patch
|
||||
3
codex-rs/apply-patch/tests/fixtures/scenarios/012_delete_directory_fails/patch.txt
vendored
Normal file
3
codex-rs/apply-patch/tests/fixtures/scenarios/012_delete_directory_fails/patch.txt
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
*** Begin Patch
|
||||
*** Delete File: dir
|
||||
*** End Patch
|
||||
3
codex-rs/apply-patch/tests/fixtures/scenarios/013_rejects_invalid_hunk_header/patch.txt
vendored
Normal file
3
codex-rs/apply-patch/tests/fixtures/scenarios/013_rejects_invalid_hunk_header/patch.txt
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
*** Begin Patch
|
||||
*** Frobnicate File: foo
|
||||
*** End Patch
|
||||
@@ -0,0 +1,2 @@
|
||||
first line
|
||||
second line
|
||||
@@ -0,0 +1 @@
|
||||
no newline at end
|
||||
@@ -0,0 +1,7 @@
|
||||
*** Begin Patch
|
||||
*** Update File: no_newline.txt
|
||||
@@
|
||||
-no newline at end
|
||||
+first line
|
||||
+second line
|
||||
*** End Patch
|
||||
@@ -0,0 +1 @@
|
||||
hello
|
||||
@@ -0,0 +1,8 @@
|
||||
*** Begin Patch
|
||||
*** Add File: created.txt
|
||||
+hello
|
||||
*** Update File: missing.txt
|
||||
@@
|
||||
-old
|
||||
+new
|
||||
*** End Patch
|
||||
@@ -0,0 +1,4 @@
|
||||
line1
|
||||
line2
|
||||
added line 1
|
||||
added line 2
|
||||
2
codex-rs/apply-patch/tests/fixtures/scenarios/016_pure_addition_update_chunk/input/input.txt
vendored
Normal file
2
codex-rs/apply-patch/tests/fixtures/scenarios/016_pure_addition_update_chunk/input/input.txt
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
line1
|
||||
line2
|
||||
6
codex-rs/apply-patch/tests/fixtures/scenarios/016_pure_addition_update_chunk/patch.txt
vendored
Normal file
6
codex-rs/apply-patch/tests/fixtures/scenarios/016_pure_addition_update_chunk/patch.txt
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
*** Begin Patch
|
||||
*** Update File: input.txt
|
||||
@@
|
||||
+added line 1
|
||||
+added line 2
|
||||
*** End Patch
|
||||
@@ -0,0 +1 @@
|
||||
new
|
||||
@@ -0,0 +1 @@
|
||||
old
|
||||
6
codex-rs/apply-patch/tests/fixtures/scenarios/017_whitespace_padded_hunk_header/patch.txt
vendored
Normal file
6
codex-rs/apply-patch/tests/fixtures/scenarios/017_whitespace_padded_hunk_header/patch.txt
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
*** Begin Patch
|
||||
*** Update File: foo.txt
|
||||
@@
|
||||
-old
|
||||
+new
|
||||
*** End Patch
|
||||
@@ -0,0 +1 @@
|
||||
two
|
||||
@@ -0,0 +1 @@
|
||||
one
|
||||
6
codex-rs/apply-patch/tests/fixtures/scenarios/018_whitespace_padded_patch_markers/patch.txt
vendored
Normal file
6
codex-rs/apply-patch/tests/fixtures/scenarios/018_whitespace_padded_patch_markers/patch.txt
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
*** Begin Patch
|
||||
*** Update File: file.txt
|
||||
@@
|
||||
-one
|
||||
+two
|
||||
*** End Patch
|
||||
18
codex-rs/apply-patch/tests/fixtures/scenarios/README.md
vendored
Normal file
18
codex-rs/apply-patch/tests/fixtures/scenarios/README.md
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
# Overview
|
||||
This directory is a collection of end to end tests for the apply-patch specification, meant to be easily portable to other languages or platforms.
|
||||
|
||||
|
||||
# Specification
|
||||
Each test case is one directory, composed of input state (input/), the patch operation (patch.txt), and the expected final state (expected/). This structure is designed to keep tests simple (i.e. test exactly one patch at a time) while still providing enough flexibility to test any given operation across files.
|
||||
|
||||
Here's what this would look like for a simple test apply-patch test case to create a new file:
|
||||
|
||||
```
|
||||
001_add/
|
||||
input/
|
||||
foo.md
|
||||
expected/
|
||||
foo.md
|
||||
bar.md
|
||||
patch.txt
|
||||
```
|
||||
@@ -1,3 +1,4 @@
|
||||
mod cli;
|
||||
mod scenarios;
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
mod tool;
|
||||
|
||||
114
codex-rs/apply-patch/tests/suite/scenarios.rs
Normal file
114
codex-rs/apply-patch/tests/suite/scenarios.rs
Normal file
@@ -0,0 +1,114 @@
|
||||
use assert_cmd::prelude::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn test_apply_patch_scenarios() -> anyhow::Result<()> {
|
||||
for scenario in fs::read_dir("tests/fixtures/scenarios")? {
|
||||
let scenario = scenario?;
|
||||
let path = scenario.path();
|
||||
if path.is_dir() {
|
||||
run_apply_patch_scenario(&path)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reads a scenario directory, copies the input files to a temporary directory, runs apply-patch,
|
||||
/// and asserts that the final state matches the expected state exactly.
|
||||
fn run_apply_patch_scenario(dir: &Path) -> anyhow::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
|
||||
// Copy the input files to the temporary directory
|
||||
let input_dir = dir.join("input");
|
||||
if input_dir.is_dir() {
|
||||
copy_dir_recursive(&input_dir, tmp.path())?;
|
||||
}
|
||||
|
||||
// Read the patch.txt file
|
||||
let patch = fs::read_to_string(dir.join("patch.txt"))?;
|
||||
|
||||
// Run apply_patch in the temporary directory. We intentionally do not assert
|
||||
// on the exit status here; the scenarios are specified purely in terms of
|
||||
// final filesystem state, which we compare below.
|
||||
Command::cargo_bin("apply_patch")?
|
||||
.arg(patch)
|
||||
.current_dir(tmp.path())
|
||||
.output()?;
|
||||
|
||||
// Assert that the final state matches the expected state exactly
|
||||
let expected_dir = dir.join("expected");
|
||||
let expected_snapshot = snapshot_dir(&expected_dir)?;
|
||||
let actual_snapshot = snapshot_dir(tmp.path())?;
|
||||
|
||||
assert_eq!(
|
||||
actual_snapshot,
|
||||
expected_snapshot,
|
||||
"Scenario {} did not match expected final state",
|
||||
dir.display()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum Entry {
|
||||
File(Vec<u8>),
|
||||
Dir,
|
||||
}
|
||||
|
||||
fn snapshot_dir(root: &Path) -> anyhow::Result<BTreeMap<PathBuf, Entry>> {
|
||||
let mut entries = BTreeMap::new();
|
||||
if root.is_dir() {
|
||||
snapshot_dir_recursive(root, root, &mut entries)?;
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn snapshot_dir_recursive(
|
||||
base: &Path,
|
||||
dir: &Path,
|
||||
entries: &mut BTreeMap<PathBuf, Entry>,
|
||||
) -> anyhow::Result<()> {
|
||||
for entry in fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let Some(stripped) = path.strip_prefix(base).ok() else {
|
||||
continue;
|
||||
};
|
||||
let rel = stripped.to_path_buf();
|
||||
let file_type = entry.file_type()?;
|
||||
if file_type.is_dir() {
|
||||
entries.insert(rel.clone(), Entry::Dir);
|
||||
snapshot_dir_recursive(base, &path, entries)?;
|
||||
} else if file_type.is_file() {
|
||||
let contents = fs::read(&path)?;
|
||||
entries.insert(rel, Entry::File(contents));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_dir_recursive(src: &Path, dst: &Path) -> anyhow::Result<()> {
|
||||
for entry in fs::read_dir(src)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let file_type = entry.file_type()?;
|
||||
let dest_path = dst.join(entry.file_name());
|
||||
if file_type.is_dir() {
|
||||
fs::create_dir_all(&dest_path)?;
|
||||
copy_dir_recursive(&path, &dest_path)?;
|
||||
} else if file_type.is_file() {
|
||||
if let Some(parent) = dest_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::copy(&path, &dest_path)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use crate::types::TurnAttemptsSiblingTurnsResponse;
|
||||
use anyhow::Result;
|
||||
use codex_core::auth::CodexAuth;
|
||||
use codex_core::default_client::get_codex_user_agent;
|
||||
use codex_protocol::account::PlanType as AccountPlanType;
|
||||
use codex_protocol::protocol::CreditsSnapshot;
|
||||
use codex_protocol::protocol::RateLimitSnapshot;
|
||||
use codex_protocol::protocol::RateLimitWindow;
|
||||
@@ -291,6 +292,7 @@ impl Client {
|
||||
primary,
|
||||
secondary,
|
||||
credits: Self::map_credits(payload.credits),
|
||||
plan_type: Some(Self::map_plan_type(payload.plan_type)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,6 +327,23 @@ impl Client {
|
||||
})
|
||||
}
|
||||
|
||||
fn map_plan_type(plan_type: crate::types::PlanType) -> AccountPlanType {
|
||||
match plan_type {
|
||||
crate::types::PlanType::Free => AccountPlanType::Free,
|
||||
crate::types::PlanType::Plus => AccountPlanType::Plus,
|
||||
crate::types::PlanType::Pro => AccountPlanType::Pro,
|
||||
crate::types::PlanType::Team => AccountPlanType::Team,
|
||||
crate::types::PlanType::Business => AccountPlanType::Business,
|
||||
crate::types::PlanType::Enterprise => AccountPlanType::Enterprise,
|
||||
crate::types::PlanType::Edu | crate::types::PlanType::Education => AccountPlanType::Edu,
|
||||
crate::types::PlanType::Guest
|
||||
| crate::types::PlanType::Go
|
||||
| crate::types::PlanType::FreeWorkspace
|
||||
| crate::types::PlanType::Quorum
|
||||
| crate::types::PlanType::K12 => AccountPlanType::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
fn window_minutes_from_seconds(seconds: i32) -> Option<i64> {
|
||||
if seconds <= 0 {
|
||||
return None;
|
||||
|
||||
@@ -40,17 +40,15 @@ prefix_rule(
|
||||
assert_eq!(
|
||||
result,
|
||||
json!({
|
||||
"match": {
|
||||
"decision": "forbidden",
|
||||
"matchedRules": [
|
||||
{
|
||||
"prefixRuleMatch": {
|
||||
"matchedPrefix": ["git", "push"],
|
||||
"decision": "forbidden"
|
||||
}
|
||||
"decision": "forbidden",
|
||||
"matchedRules": [
|
||||
{
|
||||
"prefixRuleMatch": {
|
||||
"matchedPrefix": ["git", "push"],
|
||||
"decision": "forbidden"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -127,6 +127,7 @@ impl Default for TaskText {
|
||||
#[async_trait::async_trait]
|
||||
pub trait CloudBackend: Send + Sync {
|
||||
async fn list_tasks(&self, env: Option<&str>) -> Result<Vec<TaskSummary>>;
|
||||
async fn get_task_summary(&self, id: TaskId) -> Result<TaskSummary>;
|
||||
async fn get_task_diff(&self, id: TaskId) -> Result<Option<String>>;
|
||||
/// Return assistant output messages (no diff) when available.
|
||||
async fn get_task_messages(&self, id: TaskId) -> Result<Vec<String>>;
|
||||
|
||||
@@ -63,6 +63,10 @@ impl CloudBackend for HttpClient {
|
||||
self.tasks_api().list(env).await
|
||||
}
|
||||
|
||||
async fn get_task_summary(&self, id: TaskId) -> Result<TaskSummary> {
|
||||
self.tasks_api().summary(id).await
|
||||
}
|
||||
|
||||
async fn get_task_diff(&self, id: TaskId) -> Result<Option<String>> {
|
||||
self.tasks_api().diff(id).await
|
||||
}
|
||||
@@ -149,6 +153,75 @@ mod api {
|
||||
Ok(tasks)
|
||||
}
|
||||
|
||||
pub(crate) async fn summary(&self, id: TaskId) -> Result<TaskSummary> {
|
||||
let id_str = id.0.clone();
|
||||
let (details, body, ct) = self
|
||||
.details_with_body(&id.0)
|
||||
.await
|
||||
.map_err(|e| CloudTaskError::Http(format!("get_task_details failed: {e}")))?;
|
||||
let parsed: Value = serde_json::from_str(&body).map_err(|e| {
|
||||
CloudTaskError::Http(format!(
|
||||
"Decode error for {}: {e}; content-type={ct}; body={body}",
|
||||
id.0
|
||||
))
|
||||
})?;
|
||||
let task_obj = parsed
|
||||
.get("task")
|
||||
.and_then(Value::as_object)
|
||||
.ok_or_else(|| {
|
||||
CloudTaskError::Http(format!("Task metadata missing from details for {id_str}"))
|
||||
})?;
|
||||
let status_display = parsed
|
||||
.get("task_status_display")
|
||||
.or_else(|| task_obj.get("task_status_display"))
|
||||
.and_then(Value::as_object)
|
||||
.map(|m| {
|
||||
m.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect::<HashMap<String, Value>>()
|
||||
});
|
||||
let status = map_status(status_display.as_ref());
|
||||
let mut summary = diff_summary_from_status_display(status_display.as_ref());
|
||||
if summary.files_changed == 0
|
||||
&& summary.lines_added == 0
|
||||
&& summary.lines_removed == 0
|
||||
&& let Some(diff) = details.unified_diff()
|
||||
{
|
||||
summary = diff_summary_from_diff(&diff);
|
||||
}
|
||||
let updated_at_raw = task_obj
|
||||
.get("updated_at")
|
||||
.and_then(Value::as_f64)
|
||||
.or_else(|| task_obj.get("created_at").and_then(Value::as_f64))
|
||||
.or_else(|| latest_turn_timestamp(status_display.as_ref()));
|
||||
let environment_id = task_obj
|
||||
.get("environment_id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string);
|
||||
let environment_label = env_label_from_status_display(status_display.as_ref());
|
||||
let attempt_total = attempt_total_from_status_display(status_display.as_ref());
|
||||
let title = task_obj
|
||||
.get("title")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("<untitled>")
|
||||
.to_string();
|
||||
let is_review = task_obj
|
||||
.get("is_review")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
Ok(TaskSummary {
|
||||
id,
|
||||
title,
|
||||
status,
|
||||
updated_at: parse_updated_at(updated_at_raw.as_ref()),
|
||||
environment_id,
|
||||
environment_label,
|
||||
summary,
|
||||
is_review,
|
||||
attempt_total,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn diff(&self, id: TaskId) -> Result<Option<String>> {
|
||||
let (details, body, ct) = self
|
||||
.details_with_body(&id.0)
|
||||
@@ -679,6 +752,34 @@ mod api {
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn diff_summary_from_diff(diff: &str) -> DiffSummary {
|
||||
let mut files_changed = 0usize;
|
||||
let mut lines_added = 0usize;
|
||||
let mut lines_removed = 0usize;
|
||||
for line in diff.lines() {
|
||||
if line.starts_with("diff --git ") {
|
||||
files_changed += 1;
|
||||
continue;
|
||||
}
|
||||
if line.starts_with("+++") || line.starts_with("---") || line.starts_with("@@") {
|
||||
continue;
|
||||
}
|
||||
match line.as_bytes().first() {
|
||||
Some(b'+') => lines_added += 1,
|
||||
Some(b'-') => lines_removed += 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if files_changed == 0 && !diff.trim().is_empty() {
|
||||
files_changed = 1;
|
||||
}
|
||||
DiffSummary {
|
||||
files_changed,
|
||||
lines_added,
|
||||
lines_removed,
|
||||
}
|
||||
}
|
||||
|
||||
fn diff_summary_from_status_display(v: Option<&HashMap<String, Value>>) -> DiffSummary {
|
||||
let mut out = DiffSummary::default();
|
||||
let Some(map) = v else { return out };
|
||||
@@ -700,6 +801,17 @@ mod api {
|
||||
out
|
||||
}
|
||||
|
||||
fn latest_turn_timestamp(v: Option<&HashMap<String, Value>>) -> Option<f64> {
|
||||
let map = v?;
|
||||
let latest = map
|
||||
.get("latest_turn_status_display")
|
||||
.and_then(Value::as_object)?;
|
||||
latest
|
||||
.get("updated_at")
|
||||
.or_else(|| latest.get("created_at"))
|
||||
.and_then(Value::as_f64)
|
||||
}
|
||||
|
||||
fn attempt_total_from_status_display(v: Option<&HashMap<String, Value>>) -> Option<usize> {
|
||||
let map = v?;
|
||||
let latest = map
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::ApplyOutcome;
|
||||
use crate::AttemptStatus;
|
||||
use crate::CloudBackend;
|
||||
use crate::CloudTaskError;
|
||||
use crate::DiffSummary;
|
||||
use crate::Result;
|
||||
use crate::TaskId;
|
||||
@@ -60,6 +61,14 @@ impl CloudBackend for MockClient {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn get_task_summary(&self, id: TaskId) -> Result<TaskSummary> {
|
||||
let tasks = self.list_tasks(None).await?;
|
||||
tasks
|
||||
.into_iter()
|
||||
.find(|t| t.id == id)
|
||||
.ok_or_else(|| CloudTaskError::Msg(format!("Task {} not found (mock)", id.0)))
|
||||
}
|
||||
|
||||
async fn get_task_diff(&self, id: TaskId) -> Result<Option<String>> {
|
||||
Ok(Some(mock_diff_for(&id)))
|
||||
}
|
||||
|
||||
@@ -34,6 +34,9 @@ tokio-stream = { workspace = true }
|
||||
tracing = { workspace = true, features = ["log"] }
|
||||
tracing-subscriber = { workspace = true, features = ["env-filter"] }
|
||||
unicode-width = { workspace = true }
|
||||
owo-colors = { workspace = true, features = ["supports-colors"] }
|
||||
supports-color = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
async-trait = { workspace = true }
|
||||
pretty_assertions = { workspace = true }
|
||||
|
||||
@@ -350,6 +350,7 @@ pub enum AppEvent {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Utc;
|
||||
use codex_cloud_tasks_client::CloudTaskError;
|
||||
|
||||
struct FakeBackend {
|
||||
// maps env key to titles
|
||||
@@ -385,6 +386,17 @@ mod tests {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn get_task_summary(
|
||||
&self,
|
||||
id: TaskId,
|
||||
) -> codex_cloud_tasks_client::Result<TaskSummary> {
|
||||
self.list_tasks(None)
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|t| t.id == id)
|
||||
.ok_or_else(|| CloudTaskError::Msg(format!("Task {} not found", id.0)))
|
||||
}
|
||||
|
||||
async fn get_task_diff(
|
||||
&self,
|
||||
_id: TaskId,
|
||||
|
||||
@@ -16,6 +16,12 @@ pub struct Cli {
|
||||
pub enum Command {
|
||||
/// Submit a new Codex Cloud task without launching the TUI.
|
||||
Exec(ExecCommand),
|
||||
/// Show the status of a Codex Cloud task.
|
||||
Status(StatusCommand),
|
||||
/// Apply the diff for a Codex Cloud task locally.
|
||||
Apply(ApplyCommand),
|
||||
/// Show the unified diff for a Codex Cloud task.
|
||||
Diff(DiffCommand),
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
@@ -28,6 +34,10 @@ pub struct ExecCommand {
|
||||
#[arg(long = "env", value_name = "ENV_ID")]
|
||||
pub environment: String,
|
||||
|
||||
/// Git branch to run in Codex Cloud.
|
||||
#[arg(long = "branch", value_name = "BRANCH", default_value = "main")]
|
||||
pub branch: String,
|
||||
|
||||
/// Number of assistant attempts (best-of-N).
|
||||
#[arg(
|
||||
long = "attempts",
|
||||
@@ -47,3 +57,32 @@ fn parse_attempts(input: &str) -> Result<usize, String> {
|
||||
Err("attempts must be between 1 and 4".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct StatusCommand {
|
||||
/// Codex Cloud task identifier to inspect.
|
||||
#[arg(value_name = "TASK_ID")]
|
||||
pub task_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct ApplyCommand {
|
||||
/// Codex Cloud task identifier to apply.
|
||||
#[arg(value_name = "TASK_ID")]
|
||||
pub task_id: String,
|
||||
|
||||
/// Attempt number to apply (1-based).
|
||||
#[arg(long = "attempt", value_parser = parse_attempts, value_name = "N")]
|
||||
pub attempt: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct DiffCommand {
|
||||
/// Codex Cloud task identifier to display.
|
||||
#[arg(value_name = "TASK_ID")]
|
||||
pub task_id: String,
|
||||
|
||||
/// Attempt number to display (1-based).
|
||||
#[arg(long = "attempt", value_parser = parse_attempts, value_name = "N")]
|
||||
pub attempt: Option<usize>,
|
||||
}
|
||||
|
||||
@@ -8,17 +8,24 @@ pub mod util;
|
||||
pub use cli::Cli;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use chrono::Utc;
|
||||
use codex_cloud_tasks_client::TaskStatus;
|
||||
use codex_login::AuthManager;
|
||||
use owo_colors::OwoColorize;
|
||||
use owo_colors::Stream;
|
||||
use std::cmp::Ordering;
|
||||
use std::io::IsTerminal;
|
||||
use std::io::Read;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
use supports_color::Stream as SupportStream;
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
use tracing::info;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use util::append_error_log;
|
||||
use util::format_relative_time;
|
||||
use util::set_user_agent_suffix;
|
||||
|
||||
struct ApplyJob {
|
||||
@@ -101,6 +108,7 @@ async fn run_exec_command(args: crate::cli::ExecCommand) -> anyhow::Result<()> {
|
||||
let crate::cli::ExecCommand {
|
||||
query,
|
||||
environment,
|
||||
branch,
|
||||
attempts,
|
||||
} = args;
|
||||
let ctx = init_backend("codex_cloud_tasks_exec").await?;
|
||||
@@ -110,7 +118,7 @@ async fn run_exec_command(args: crate::cli::ExecCommand) -> anyhow::Result<()> {
|
||||
&*ctx.backend,
|
||||
&env_id,
|
||||
&prompt,
|
||||
"main",
|
||||
&branch,
|
||||
false,
|
||||
attempts,
|
||||
)
|
||||
@@ -192,6 +200,273 @@ fn resolve_query_input(query_arg: Option<String>) -> anyhow::Result<String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_task_id(raw: &str) -> anyhow::Result<codex_cloud_tasks_client::TaskId> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
anyhow::bail!("task id must not be empty");
|
||||
}
|
||||
let without_fragment = trimmed.split('#').next().unwrap_or(trimmed);
|
||||
let without_query = without_fragment
|
||||
.split('?')
|
||||
.next()
|
||||
.unwrap_or(without_fragment);
|
||||
let id = without_query
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.unwrap_or(without_query)
|
||||
.trim();
|
||||
if id.is_empty() {
|
||||
anyhow::bail!("task id must not be empty");
|
||||
}
|
||||
Ok(codex_cloud_tasks_client::TaskId(id.to_string()))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct AttemptDiffData {
|
||||
placement: Option<i64>,
|
||||
created_at: Option<chrono::DateTime<Utc>>,
|
||||
diff: String,
|
||||
}
|
||||
|
||||
fn cmp_attempt(lhs: &AttemptDiffData, rhs: &AttemptDiffData) -> Ordering {
|
||||
match (lhs.placement, rhs.placement) {
|
||||
(Some(a), Some(b)) => a.cmp(&b),
|
||||
(Some(_), None) => Ordering::Less,
|
||||
(None, Some(_)) => Ordering::Greater,
|
||||
(None, None) => match (lhs.created_at, rhs.created_at) {
|
||||
(Some(a), Some(b)) => a.cmp(&b),
|
||||
(Some(_), None) => Ordering::Less,
|
||||
(None, Some(_)) => Ordering::Greater,
|
||||
(None, None) => Ordering::Equal,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn collect_attempt_diffs(
|
||||
backend: &dyn codex_cloud_tasks_client::CloudBackend,
|
||||
task_id: &codex_cloud_tasks_client::TaskId,
|
||||
) -> anyhow::Result<Vec<AttemptDiffData>> {
|
||||
let text =
|
||||
codex_cloud_tasks_client::CloudBackend::get_task_text(backend, task_id.clone()).await?;
|
||||
let mut attempts = Vec::new();
|
||||
if let Some(diff) =
|
||||
codex_cloud_tasks_client::CloudBackend::get_task_diff(backend, task_id.clone()).await?
|
||||
{
|
||||
attempts.push(AttemptDiffData {
|
||||
placement: text.attempt_placement,
|
||||
created_at: None,
|
||||
diff,
|
||||
});
|
||||
}
|
||||
if let Some(turn_id) = text.turn_id {
|
||||
let siblings = codex_cloud_tasks_client::CloudBackend::list_sibling_attempts(
|
||||
backend,
|
||||
task_id.clone(),
|
||||
turn_id,
|
||||
)
|
||||
.await?;
|
||||
for sibling in siblings {
|
||||
if let Some(diff) = sibling.diff {
|
||||
attempts.push(AttemptDiffData {
|
||||
placement: sibling.attempt_placement,
|
||||
created_at: sibling.created_at,
|
||||
diff,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
attempts.sort_by(cmp_attempt);
|
||||
if attempts.is_empty() {
|
||||
anyhow::bail!(
|
||||
"No diff available for task {}; it may still be running.",
|
||||
task_id.0
|
||||
);
|
||||
}
|
||||
Ok(attempts)
|
||||
}
|
||||
|
||||
fn select_attempt(
|
||||
attempts: &[AttemptDiffData],
|
||||
attempt: Option<usize>,
|
||||
) -> anyhow::Result<&AttemptDiffData> {
|
||||
if attempts.is_empty() {
|
||||
anyhow::bail!("No attempts available");
|
||||
}
|
||||
let desired = attempt.unwrap_or(1);
|
||||
let idx = desired
|
||||
.checked_sub(1)
|
||||
.ok_or_else(|| anyhow!("attempt must be at least 1"))?;
|
||||
if idx >= attempts.len() {
|
||||
anyhow::bail!(
|
||||
"Attempt {desired} not available; only {} attempt(s) found",
|
||||
attempts.len()
|
||||
);
|
||||
}
|
||||
Ok(&attempts[idx])
|
||||
}
|
||||
|
||||
fn task_status_label(status: &TaskStatus) -> &'static str {
|
||||
match status {
|
||||
TaskStatus::Pending => "PENDING",
|
||||
TaskStatus::Ready => "READY",
|
||||
TaskStatus::Applied => "APPLIED",
|
||||
TaskStatus::Error => "ERROR",
|
||||
}
|
||||
}
|
||||
|
||||
fn summary_line(summary: &codex_cloud_tasks_client::DiffSummary, colorize: bool) -> String {
|
||||
if summary.files_changed == 0 && summary.lines_added == 0 && summary.lines_removed == 0 {
|
||||
let base = "no diff";
|
||||
return if colorize {
|
||||
base.if_supports_color(Stream::Stdout, |t| t.dimmed())
|
||||
.to_string()
|
||||
} else {
|
||||
base.to_string()
|
||||
};
|
||||
}
|
||||
let adds = summary.lines_added;
|
||||
let dels = summary.lines_removed;
|
||||
let files = summary.files_changed;
|
||||
if colorize {
|
||||
let adds_raw = format!("+{adds}");
|
||||
let adds_str = adds_raw
|
||||
.as_str()
|
||||
.if_supports_color(Stream::Stdout, |t| t.green())
|
||||
.to_string();
|
||||
let dels_raw = format!("-{dels}");
|
||||
let dels_str = dels_raw
|
||||
.as_str()
|
||||
.if_supports_color(Stream::Stdout, |t| t.red())
|
||||
.to_string();
|
||||
let bullet = "•"
|
||||
.if_supports_color(Stream::Stdout, |t| t.dimmed())
|
||||
.to_string();
|
||||
let file_label = "file"
|
||||
.if_supports_color(Stream::Stdout, |t| t.dimmed())
|
||||
.to_string();
|
||||
let plural = if files == 1 { "" } else { "s" };
|
||||
format!("{adds_str}/{dels_str} {bullet} {files} {file_label}{plural}")
|
||||
} else {
|
||||
format!(
|
||||
"+{adds}/-{dels} • {files} file{}",
|
||||
if files == 1 { "" } else { "s" }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn format_task_status_lines(
|
||||
task: &codex_cloud_tasks_client::TaskSummary,
|
||||
now: chrono::DateTime<Utc>,
|
||||
colorize: bool,
|
||||
) -> Vec<String> {
|
||||
let mut lines = Vec::new();
|
||||
let status = task_status_label(&task.status);
|
||||
let status = if colorize {
|
||||
match task.status {
|
||||
TaskStatus::Ready => status
|
||||
.if_supports_color(Stream::Stdout, |t| t.green())
|
||||
.to_string(),
|
||||
TaskStatus::Pending => status
|
||||
.if_supports_color(Stream::Stdout, |t| t.magenta())
|
||||
.to_string(),
|
||||
TaskStatus::Applied => status
|
||||
.if_supports_color(Stream::Stdout, |t| t.blue())
|
||||
.to_string(),
|
||||
TaskStatus::Error => status
|
||||
.if_supports_color(Stream::Stdout, |t| t.red())
|
||||
.to_string(),
|
||||
}
|
||||
} else {
|
||||
status.to_string()
|
||||
};
|
||||
lines.push(format!("[{status}] {}", task.title));
|
||||
let mut meta_parts = Vec::new();
|
||||
if let Some(label) = task.environment_label.as_deref().filter(|s| !s.is_empty()) {
|
||||
if colorize {
|
||||
meta_parts.push(
|
||||
label
|
||||
.if_supports_color(Stream::Stdout, |t| t.dimmed())
|
||||
.to_string(),
|
||||
);
|
||||
} else {
|
||||
meta_parts.push(label.to_string());
|
||||
}
|
||||
} else if let Some(id) = task.environment_id.as_deref() {
|
||||
if colorize {
|
||||
meta_parts.push(
|
||||
id.if_supports_color(Stream::Stdout, |t| t.dimmed())
|
||||
.to_string(),
|
||||
);
|
||||
} else {
|
||||
meta_parts.push(id.to_string());
|
||||
}
|
||||
}
|
||||
let when = format_relative_time(now, task.updated_at);
|
||||
meta_parts.push(if colorize {
|
||||
when.as_str()
|
||||
.if_supports_color(Stream::Stdout, |t| t.dimmed())
|
||||
.to_string()
|
||||
} else {
|
||||
when
|
||||
});
|
||||
let sep = if colorize {
|
||||
" • "
|
||||
.if_supports_color(Stream::Stdout, |t| t.dimmed())
|
||||
.to_string()
|
||||
} else {
|
||||
" • ".to_string()
|
||||
};
|
||||
lines.push(meta_parts.join(&sep));
|
||||
lines.push(summary_line(&task.summary, colorize));
|
||||
lines
|
||||
}
|
||||
|
||||
async fn run_status_command(args: crate::cli::StatusCommand) -> anyhow::Result<()> {
|
||||
let ctx = init_backend("codex_cloud_tasks_status").await?;
|
||||
let task_id = parse_task_id(&args.task_id)?;
|
||||
let summary =
|
||||
codex_cloud_tasks_client::CloudBackend::get_task_summary(&*ctx.backend, task_id).await?;
|
||||
let now = Utc::now();
|
||||
let colorize = supports_color::on(SupportStream::Stdout).is_some();
|
||||
for line in format_task_status_lines(&summary, now, colorize) {
|
||||
println!("{line}");
|
||||
}
|
||||
if !matches!(summary.status, TaskStatus::Ready) {
|
||||
std::process::exit(1);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_diff_command(args: crate::cli::DiffCommand) -> anyhow::Result<()> {
|
||||
let ctx = init_backend("codex_cloud_tasks_diff").await?;
|
||||
let task_id = parse_task_id(&args.task_id)?;
|
||||
let attempts = collect_attempt_diffs(&*ctx.backend, &task_id).await?;
|
||||
let selected = select_attempt(&attempts, args.attempt)?;
|
||||
print!("{}", selected.diff);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_apply_command(args: crate::cli::ApplyCommand) -> anyhow::Result<()> {
|
||||
let ctx = init_backend("codex_cloud_tasks_apply").await?;
|
||||
let task_id = parse_task_id(&args.task_id)?;
|
||||
let attempts = collect_attempt_diffs(&*ctx.backend, &task_id).await?;
|
||||
let selected = select_attempt(&attempts, args.attempt)?;
|
||||
let outcome = codex_cloud_tasks_client::CloudBackend::apply_task(
|
||||
&*ctx.backend,
|
||||
task_id,
|
||||
Some(selected.diff.clone()),
|
||||
)
|
||||
.await?;
|
||||
println!("{}", outcome.message);
|
||||
if !matches!(
|
||||
outcome.status,
|
||||
codex_cloud_tasks_client::ApplyStatus::Success
|
||||
) {
|
||||
std::process::exit(1);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn level_from_status(status: codex_cloud_tasks_client::ApplyStatus) -> app::ApplyResultLevel {
|
||||
match status {
|
||||
codex_cloud_tasks_client::ApplyStatus::Success => app::ApplyResultLevel::Success,
|
||||
@@ -321,6 +596,9 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> an
|
||||
if let Some(command) = cli.command {
|
||||
return match command {
|
||||
crate::cli::Command::Exec(args) => run_exec_command(args).await,
|
||||
crate::cli::Command::Status(args) => run_status_command(args).await,
|
||||
crate::cli::Command::Apply(args) => run_apply_command(args).await,
|
||||
crate::cli::Command::Diff(args) => run_diff_command(args).await,
|
||||
};
|
||||
}
|
||||
let Cli { .. } = cli;
|
||||
@@ -1712,14 +1990,111 @@ fn pretty_lines_from_error(raw: &str) -> Vec<String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_cloud_tasks_client::DiffSummary;
|
||||
use codex_cloud_tasks_client::MockClient;
|
||||
use codex_cloud_tasks_client::TaskId;
|
||||
use codex_cloud_tasks_client::TaskStatus;
|
||||
use codex_cloud_tasks_client::TaskSummary;
|
||||
use codex_tui::ComposerAction;
|
||||
use codex_tui::ComposerInput;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
#[test]
|
||||
fn format_task_status_lines_with_diff_and_label() {
|
||||
let now = Utc::now();
|
||||
let task = TaskSummary {
|
||||
id: TaskId("task_1".to_string()),
|
||||
title: "Example task".to_string(),
|
||||
status: TaskStatus::Ready,
|
||||
updated_at: now,
|
||||
environment_id: Some("env-1".to_string()),
|
||||
environment_label: Some("Env".to_string()),
|
||||
summary: DiffSummary {
|
||||
files_changed: 3,
|
||||
lines_added: 5,
|
||||
lines_removed: 2,
|
||||
},
|
||||
is_review: false,
|
||||
attempt_total: None,
|
||||
};
|
||||
let lines = format_task_status_lines(&task, now, false);
|
||||
assert_eq!(
|
||||
lines,
|
||||
vec![
|
||||
"[READY] Example task".to_string(),
|
||||
"Env • 0s ago".to_string(),
|
||||
"+5/-2 • 3 files".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_task_status_lines_without_diff_falls_back() {
|
||||
let now = Utc::now();
|
||||
let task = TaskSummary {
|
||||
id: TaskId("task_2".to_string()),
|
||||
title: "No diff task".to_string(),
|
||||
status: TaskStatus::Pending,
|
||||
updated_at: now,
|
||||
environment_id: Some("env-2".to_string()),
|
||||
environment_label: None,
|
||||
summary: DiffSummary::default(),
|
||||
is_review: false,
|
||||
attempt_total: Some(1),
|
||||
};
|
||||
let lines = format_task_status_lines(&task, now, false);
|
||||
assert_eq!(
|
||||
lines,
|
||||
vec![
|
||||
"[PENDING] No diff task".to_string(),
|
||||
"env-2 • 0s ago".to_string(),
|
||||
"no diff".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn collect_attempt_diffs_includes_sibling_attempts() {
|
||||
let backend = MockClient;
|
||||
let task_id = parse_task_id("https://chatgpt.com/codex/tasks/T-1000").expect("id");
|
||||
let attempts = collect_attempt_diffs(&backend, &task_id)
|
||||
.await
|
||||
.expect("attempts");
|
||||
assert_eq!(attempts.len(), 2);
|
||||
assert_eq!(attempts[0].placement, Some(0));
|
||||
assert_eq!(attempts[1].placement, Some(1));
|
||||
assert!(!attempts[0].diff.is_empty());
|
||||
assert!(!attempts[1].diff.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_attempt_validates_bounds() {
|
||||
let attempts = vec![AttemptDiffData {
|
||||
placement: Some(0),
|
||||
created_at: None,
|
||||
diff: "diff --git a/file b/file\n".to_string(),
|
||||
}];
|
||||
let first = select_attempt(&attempts, Some(1)).expect("attempt 1");
|
||||
assert_eq!(first.diff, "diff --git a/file b/file\n");
|
||||
assert!(select_attempt(&attempts, Some(2)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_task_id_from_url_and_raw() {
|
||||
let raw = parse_task_id("task_i_abc123").expect("raw id");
|
||||
assert_eq!(raw.0, "task_i_abc123");
|
||||
let url =
|
||||
parse_task_id("https://chatgpt.com/codex/tasks/task_i_123456?foo=bar").expect("url id");
|
||||
assert_eq!(url.0, "task_i_123456");
|
||||
assert!(parse_task_id(" ").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "very slow"]
|
||||
fn composer_input_renders_typed_characters() {
|
||||
|
||||
@@ -20,8 +20,7 @@ use std::time::Instant;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::app::AttemptView;
|
||||
use chrono::Local;
|
||||
use chrono::Utc;
|
||||
use crate::util::format_relative_time_now;
|
||||
use codex_cloud_tasks_client::AttemptStatus;
|
||||
use codex_cloud_tasks_client::TaskStatus;
|
||||
use codex_tui::render_markdown_text;
|
||||
@@ -804,7 +803,7 @@ fn render_task_item(_app: &App, t: &codex_cloud_tasks_client::TaskSummary) -> Li
|
||||
if let Some(lbl) = t.environment_label.as_ref().filter(|s| !s.is_empty()) {
|
||||
meta.push(lbl.clone().dim());
|
||||
}
|
||||
let when = format_relative_time(t.updated_at).dim();
|
||||
let when = format_relative_time_now(t.updated_at).dim();
|
||||
if !meta.is_empty() {
|
||||
meta.push(" ".into());
|
||||
meta.push("•".dim());
|
||||
@@ -841,27 +840,6 @@ fn render_task_item(_app: &App, t: &codex_cloud_tasks_client::TaskSummary) -> Li
|
||||
ListItem::new(vec![title, meta_line, sub, spacer])
|
||||
}
|
||||
|
||||
fn format_relative_time(ts: chrono::DateTime<Utc>) -> String {
|
||||
let now = Utc::now();
|
||||
let mut secs = (now - ts).num_seconds();
|
||||
if secs < 0 {
|
||||
secs = 0;
|
||||
}
|
||||
if secs < 60 {
|
||||
return format!("{secs}s ago");
|
||||
}
|
||||
let mins = secs / 60;
|
||||
if mins < 60 {
|
||||
return format!("{mins}m ago");
|
||||
}
|
||||
let hours = mins / 60;
|
||||
if hours < 24 {
|
||||
return format!("{hours}h ago");
|
||||
}
|
||||
let local = ts.with_timezone(&Local);
|
||||
local.format("%b %e %H:%M").to_string()
|
||||
}
|
||||
|
||||
fn draw_inline_spinner(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use base64::Engine as _;
|
||||
use chrono::DateTime;
|
||||
use chrono::Local;
|
||||
use chrono::Utc;
|
||||
use reqwest::header::HeaderMap;
|
||||
|
||||
@@ -120,3 +122,27 @@ pub fn task_url(base_url: &str, task_id: &str) -> String {
|
||||
}
|
||||
format!("{normalized}/codex/tasks/{task_id}")
|
||||
}
|
||||
|
||||
pub fn format_relative_time(reference: DateTime<Utc>, ts: DateTime<Utc>) -> String {
|
||||
let mut secs = (reference - ts).num_seconds();
|
||||
if secs < 0 {
|
||||
secs = 0;
|
||||
}
|
||||
if secs < 60 {
|
||||
return format!("{secs}s ago");
|
||||
}
|
||||
let mins = secs / 60;
|
||||
if mins < 60 {
|
||||
return format!("{mins}m ago");
|
||||
}
|
||||
let hours = mins / 60;
|
||||
if hours < 24 {
|
||||
return format!("{hours}h ago");
|
||||
}
|
||||
let local = ts.with_timezone(&Local);
|
||||
local.format("%b %e %H:%M").to_string()
|
||||
}
|
||||
|
||||
pub fn format_relative_time_now(ts: DateTime<Utc>) -> String {
|
||||
format_relative_time(Utc::now(), ts)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ anyhow = { workspace = true }
|
||||
assert_matches = { workspace = true }
|
||||
pretty_assertions = { workspace = true }
|
||||
tokio-test = { workspace = true }
|
||||
wiremock = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use crate::error::ApiError;
|
||||
use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig;
|
||||
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
|
||||
use codex_protocol::config_types::Verbosity as VerbosityConfig;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
|
||||
use codex_protocol::protocol::RateLimitSnapshot;
|
||||
use codex_protocol::protocol::TokenUsage;
|
||||
use futures::Stream;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod chat;
|
||||
pub mod compact;
|
||||
pub mod models;
|
||||
pub mod responses;
|
||||
mod streaming;
|
||||
|
||||
217
codex-rs/codex-api/src/endpoint/models.rs
Normal file
217
codex-rs/codex-api/src/endpoint/models.rs
Normal file
@@ -0,0 +1,217 @@
|
||||
use crate::auth::AuthProvider;
|
||||
use crate::auth::add_auth_headers;
|
||||
use crate::error::ApiError;
|
||||
use crate::provider::Provider;
|
||||
use crate::telemetry::run_with_request_telemetry;
|
||||
use codex_client::HttpTransport;
|
||||
use codex_client::RequestTelemetry;
|
||||
use codex_protocol::openai_models::ModelsResponse;
|
||||
use http::HeaderMap;
|
||||
use http::Method;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct ModelsClient<T: HttpTransport, A: AuthProvider> {
|
||||
transport: T,
|
||||
provider: Provider,
|
||||
auth: A,
|
||||
request_telemetry: Option<Arc<dyn RequestTelemetry>>,
|
||||
}
|
||||
|
||||
impl<T: HttpTransport, A: AuthProvider> ModelsClient<T, A> {
|
||||
pub fn new(transport: T, provider: Provider, auth: A) -> Self {
|
||||
Self {
|
||||
transport,
|
||||
provider,
|
||||
auth,
|
||||
request_telemetry: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_telemetry(mut self, request: Option<Arc<dyn RequestTelemetry>>) -> Self {
|
||||
self.request_telemetry = request;
|
||||
self
|
||||
}
|
||||
|
||||
fn path(&self) -> &'static str {
|
||||
"models"
|
||||
}
|
||||
|
||||
pub async fn list_models(
|
||||
&self,
|
||||
client_version: &str,
|
||||
extra_headers: HeaderMap,
|
||||
) -> Result<ModelsResponse, ApiError> {
|
||||
let builder = || {
|
||||
let mut req = self.provider.build_request(Method::GET, self.path());
|
||||
req.headers.extend(extra_headers.clone());
|
||||
|
||||
let separator = if req.url.contains('?') { '&' } else { '?' };
|
||||
req.url = format!("{}{}client_version={client_version}", req.url, separator);
|
||||
|
||||
add_auth_headers(&self.auth, req)
|
||||
};
|
||||
|
||||
let resp = run_with_request_telemetry(
|
||||
self.provider.retry.to_policy(),
|
||||
self.request_telemetry.clone(),
|
||||
builder,
|
||||
|req| self.transport.execute(req),
|
||||
)
|
||||
.await?;
|
||||
|
||||
serde_json::from_slice::<ModelsResponse>(&resp.body).map_err(|e| {
|
||||
ApiError::Stream(format!(
|
||||
"failed to decode models response: {e}; body: {}",
|
||||
String::from_utf8_lossy(&resp.body)
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::provider::RetryConfig;
|
||||
use crate::provider::WireApi;
|
||||
use async_trait::async_trait;
|
||||
use codex_client::Request;
|
||||
use codex_client::Response;
|
||||
use codex_client::StreamResponse;
|
||||
use codex_client::TransportError;
|
||||
use http::HeaderMap;
|
||||
use http::StatusCode;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct CapturingTransport {
|
||||
last_request: Arc<Mutex<Option<Request>>>,
|
||||
body: Arc<ModelsResponse>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl HttpTransport for CapturingTransport {
|
||||
async fn execute(&self, req: Request) -> Result<Response, TransportError> {
|
||||
*self.last_request.lock().unwrap() = Some(req);
|
||||
let body = serde_json::to_vec(&*self.body).unwrap();
|
||||
Ok(Response {
|
||||
status: StatusCode::OK,
|
||||
headers: HeaderMap::new(),
|
||||
body: body.into(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn stream(&self, _req: Request) -> Result<StreamResponse, TransportError> {
|
||||
Err(TransportError::Build("stream should not run".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct DummyAuth;
|
||||
|
||||
impl AuthProvider for DummyAuth {
|
||||
fn bearer_token(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn provider(base_url: &str) -> Provider {
|
||||
Provider {
|
||||
name: "test".to_string(),
|
||||
base_url: base_url.to_string(),
|
||||
query_params: None,
|
||||
wire: WireApi::Responses,
|
||||
headers: HeaderMap::new(),
|
||||
retry: RetryConfig {
|
||||
max_attempts: 1,
|
||||
base_delay: Duration::from_millis(1),
|
||||
retry_429: false,
|
||||
retry_5xx: true,
|
||||
retry_transport: true,
|
||||
},
|
||||
stream_idle_timeout: Duration::from_secs(1),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn appends_client_version_query() {
|
||||
let response = ModelsResponse { models: Vec::new() };
|
||||
|
||||
let transport = CapturingTransport {
|
||||
last_request: Arc::new(Mutex::new(None)),
|
||||
body: Arc::new(response),
|
||||
};
|
||||
|
||||
let client = ModelsClient::new(
|
||||
transport.clone(),
|
||||
provider("https://example.com/api/codex"),
|
||||
DummyAuth,
|
||||
);
|
||||
|
||||
let result = client
|
||||
.list_models("0.99.0", HeaderMap::new())
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(result.models.len(), 0);
|
||||
|
||||
let url = transport
|
||||
.last_request
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.url
|
||||
.clone();
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://example.com/api/codex/models?client_version=0.99.0"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parses_models_response() {
|
||||
let response = ModelsResponse {
|
||||
models: vec![
|
||||
serde_json::from_value(json!({
|
||||
"slug": "gpt-test",
|
||||
"display_name": "gpt-test",
|
||||
"description": "desc",
|
||||
"default_reasoning_level": "medium",
|
||||
"supported_reasoning_levels": [{"effort": "low", "description": "low"}, {"effort": "medium", "description": "medium"}, {"effort": "high", "description": "high"}],
|
||||
"shell_type": "shell_command",
|
||||
"visibility": "list",
|
||||
"minimal_client_version": [0, 99, 0],
|
||||
"supported_in_api": true,
|
||||
"priority": 1,
|
||||
"upgrade": null,
|
||||
}))
|
||||
.unwrap(),
|
||||
],
|
||||
};
|
||||
|
||||
let transport = CapturingTransport {
|
||||
last_request: Arc::new(Mutex::new(None)),
|
||||
body: Arc::new(response),
|
||||
};
|
||||
|
||||
let client = ModelsClient::new(
|
||||
transport,
|
||||
provider("https://example.com/api/codex"),
|
||||
DummyAuth,
|
||||
);
|
||||
|
||||
let result = client
|
||||
.list_models("0.99.0", HeaderMap::new())
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(result.models.len(), 1);
|
||||
assert_eq!(result.models[0].slug, "gpt-test");
|
||||
assert_eq!(result.models[0].supported_in_api, true);
|
||||
assert_eq!(result.models[0].priority, 1);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ pub use crate::common::create_text_param_for_request;
|
||||
pub use crate::endpoint::chat::AggregateStreamExt;
|
||||
pub use crate::endpoint::chat::ChatClient;
|
||||
pub use crate::endpoint::compact::CompactClient;
|
||||
pub use crate::endpoint::models::ModelsClient;
|
||||
pub use crate::endpoint::responses::ResponsesClient;
|
||||
pub use crate::endpoint::responses::ResponsesOptions;
|
||||
pub use crate::error::ApiError;
|
||||
|
||||
@@ -37,6 +37,7 @@ pub fn parse_rate_limit(headers: &HeaderMap) -> Option<RateLimitSnapshot> {
|
||||
primary,
|
||||
secondary,
|
||||
credits,
|
||||
plan_type: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ use eventsource_stream::Eventsource;
|
||||
use futures::Stream;
|
||||
use futures::StreamExt;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::Instant;
|
||||
@@ -41,12 +42,17 @@ pub async fn process_chat_sse<S>(
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
struct ToolCallState {
|
||||
id: Option<String>,
|
||||
name: Option<String>,
|
||||
arguments: String,
|
||||
}
|
||||
|
||||
let mut tool_calls: HashMap<String, ToolCallState> = HashMap::new();
|
||||
let mut tool_call_order: Vec<String> = Vec::new();
|
||||
let mut tool_calls: HashMap<usize, ToolCallState> = HashMap::new();
|
||||
let mut tool_call_order: Vec<usize> = Vec::new();
|
||||
let mut tool_call_order_seen: HashSet<usize> = HashSet::new();
|
||||
let mut tool_call_index_by_id: HashMap<String, usize> = HashMap::new();
|
||||
let mut next_tool_call_index = 0usize;
|
||||
let mut last_tool_call_index: Option<usize> = None;
|
||||
let mut assistant_item: Option<ResponseItem> = None;
|
||||
let mut reasoning_item: Option<ResponseItem> = None;
|
||||
let mut completed_sent = false;
|
||||
@@ -149,26 +155,55 @@ pub async fn process_chat_sse<S>(
|
||||
|
||||
if let Some(tool_call_values) = delta.get("tool_calls").and_then(|c| c.as_array()) {
|
||||
for tool_call in tool_call_values {
|
||||
let id = tool_call
|
||||
.get("id")
|
||||
.and_then(|i| i.as_str())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| format!("tool-call-{}", tool_call_order.len()));
|
||||
let mut index = tool_call
|
||||
.get("index")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.map(|i| i as usize);
|
||||
|
||||
let call_state = tool_calls.entry(id.clone()).or_default();
|
||||
if !tool_call_order.contains(&id) {
|
||||
tool_call_order.push(id.clone());
|
||||
let mut call_id_for_lookup = None;
|
||||
if let Some(call_id) = tool_call.get("id").and_then(|i| i.as_str()) {
|
||||
call_id_for_lookup = Some(call_id.to_string());
|
||||
if let Some(existing) = tool_call_index_by_id.get(call_id) {
|
||||
index = Some(*existing);
|
||||
}
|
||||
}
|
||||
|
||||
if index.is_none() && call_id_for_lookup.is_none() {
|
||||
index = last_tool_call_index;
|
||||
}
|
||||
|
||||
let index = index.unwrap_or_else(|| {
|
||||
while tool_calls.contains_key(&next_tool_call_index) {
|
||||
next_tool_call_index += 1;
|
||||
}
|
||||
let idx = next_tool_call_index;
|
||||
next_tool_call_index += 1;
|
||||
idx
|
||||
});
|
||||
|
||||
let call_state = tool_calls.entry(index).or_default();
|
||||
if tool_call_order_seen.insert(index) {
|
||||
tool_call_order.push(index);
|
||||
}
|
||||
|
||||
if let Some(id) = tool_call.get("id").and_then(|i| i.as_str()) {
|
||||
call_state.id.get_or_insert_with(|| id.to_string());
|
||||
tool_call_index_by_id.entry(id.to_string()).or_insert(index);
|
||||
}
|
||||
|
||||
if let Some(func) = tool_call.get("function") {
|
||||
if let Some(fname) = func.get("name").and_then(|n| n.as_str()) {
|
||||
call_state.name = Some(fname.to_string());
|
||||
if let Some(fname) = func.get("name").and_then(|n| n.as_str())
|
||||
&& !fname.is_empty()
|
||||
{
|
||||
call_state.name.get_or_insert_with(|| fname.to_string());
|
||||
}
|
||||
if let Some(arguments) = func.get("arguments").and_then(|a| a.as_str())
|
||||
{
|
||||
call_state.arguments.push_str(arguments);
|
||||
}
|
||||
}
|
||||
|
||||
last_tool_call_index = Some(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -222,13 +257,25 @@ pub async fn process_chat_sse<S>(
|
||||
.await;
|
||||
}
|
||||
|
||||
for call_id in tool_call_order.drain(..) {
|
||||
let state = tool_calls.remove(&call_id).unwrap_or_default();
|
||||
for index in tool_call_order.drain(..) {
|
||||
let Some(state) = tool_calls.remove(&index) else {
|
||||
continue;
|
||||
};
|
||||
tool_call_order_seen.remove(&index);
|
||||
let ToolCallState {
|
||||
id,
|
||||
name,
|
||||
arguments,
|
||||
} = state;
|
||||
let Some(name) = name else {
|
||||
debug!("Skipping tool call at index {index} because name is missing");
|
||||
continue;
|
||||
};
|
||||
let item = ResponseItem::FunctionCall {
|
||||
id: None,
|
||||
name: state.name.unwrap_or_default(),
|
||||
arguments: state.arguments,
|
||||
call_id: call_id.clone(),
|
||||
name,
|
||||
arguments,
|
||||
call_id: id.unwrap_or_else(|| format!("tool-call-{index}")),
|
||||
};
|
||||
let _ = tx_event.send(Ok(ResponseEvent::OutputItemDone(item))).await;
|
||||
}
|
||||
@@ -333,6 +380,59 @@ mod tests {
|
||||
out
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concatenates_tool_call_arguments_across_deltas() {
|
||||
let delta_name = json!({
|
||||
"choices": [{
|
||||
"delta": {
|
||||
"tool_calls": [{
|
||||
"id": "call_a",
|
||||
"index": 0,
|
||||
"function": { "name": "do_a" }
|
||||
}]
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
||||
let delta_args_1 = json!({
|
||||
"choices": [{
|
||||
"delta": {
|
||||
"tool_calls": [{
|
||||
"index": 0,
|
||||
"function": { "arguments": "{ \"foo\":" }
|
||||
}]
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
||||
let delta_args_2 = json!({
|
||||
"choices": [{
|
||||
"delta": {
|
||||
"tool_calls": [{
|
||||
"index": 0,
|
||||
"function": { "arguments": "1}" }
|
||||
}]
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
||||
let finish = json!({
|
||||
"choices": [{
|
||||
"finish_reason": "tool_calls"
|
||||
}]
|
||||
});
|
||||
|
||||
let body = build_body(&[delta_name, delta_args_1, delta_args_2, finish]);
|
||||
let events = collect_events(&body).await;
|
||||
assert_matches!(
|
||||
&events[..],
|
||||
[
|
||||
ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id, name, arguments, .. }),
|
||||
ResponseEvent::Completed { .. }
|
||||
] if call_id == "call_a" && name == "do_a" && arguments == "{ \"foo\":1}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn emits_multiple_tool_calls() {
|
||||
let delta_a = json!({
|
||||
@@ -365,50 +465,74 @@ mod tests {
|
||||
|
||||
let body = build_body(&[delta_a, delta_b, finish]);
|
||||
let events = collect_events(&body).await;
|
||||
assert_eq!(events.len(), 3);
|
||||
|
||||
assert_matches!(
|
||||
&events[0],
|
||||
ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id, name, arguments, .. })
|
||||
if call_id == "call_a" && name == "do_a" && arguments == "{\"foo\":1}"
|
||||
&events[..],
|
||||
[
|
||||
ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id: call_a, name: name_a, arguments: args_a, .. }),
|
||||
ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id: call_b, name: name_b, arguments: args_b, .. }),
|
||||
ResponseEvent::Completed { .. }
|
||||
] if call_a == "call_a" && name_a == "do_a" && args_a == "{\"foo\":1}" && call_b == "call_b" && name_b == "do_b" && args_b == "{\"bar\":2}"
|
||||
);
|
||||
assert_matches!(
|
||||
&events[1],
|
||||
ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id, name, arguments, .. })
|
||||
if call_id == "call_b" && name == "do_b" && arguments == "{\"bar\":2}"
|
||||
);
|
||||
assert_matches!(events[2], ResponseEvent::Completed { .. });
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concatenates_tool_call_arguments_across_deltas() {
|
||||
let delta_name = json!({
|
||||
async fn emits_tool_calls_for_multiple_choices() {
|
||||
let payload = json!({
|
||||
"choices": [
|
||||
{
|
||||
"delta": {
|
||||
"tool_calls": [{
|
||||
"id": "call_a",
|
||||
"index": 0,
|
||||
"function": { "name": "do_a", "arguments": "{}" }
|
||||
}]
|
||||
},
|
||||
"finish_reason": "tool_calls"
|
||||
},
|
||||
{
|
||||
"delta": {
|
||||
"tool_calls": [{
|
||||
"id": "call_b",
|
||||
"index": 0,
|
||||
"function": { "name": "do_b", "arguments": "{}" }
|
||||
}]
|
||||
},
|
||||
"finish_reason": "tool_calls"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let body = build_body(&[payload]);
|
||||
let events = collect_events(&body).await;
|
||||
assert_matches!(
|
||||
&events[..],
|
||||
[
|
||||
ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id: call_a, name: name_a, arguments: args_a, .. }),
|
||||
ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id: call_b, name: name_b, arguments: args_b, .. }),
|
||||
ResponseEvent::Completed { .. }
|
||||
] if call_a == "call_a" && name_a == "do_a" && args_a == "{}" && call_b == "call_b" && name_b == "do_b" && args_b == "{}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn merges_tool_calls_by_index_when_id_missing_on_subsequent_deltas() {
|
||||
let delta_with_id = json!({
|
||||
"choices": [{
|
||||
"delta": {
|
||||
"tool_calls": [{
|
||||
"index": 0,
|
||||
"id": "call_a",
|
||||
"function": { "name": "do_a" }
|
||||
"function": { "name": "do_a", "arguments": "{ \"foo\":" }
|
||||
}]
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
||||
let delta_args_1 = json!({
|
||||
let delta_without_id = json!({
|
||||
"choices": [{
|
||||
"delta": {
|
||||
"tool_calls": [{
|
||||
"id": "call_a",
|
||||
"function": { "arguments": "{ \"foo\":" }
|
||||
}]
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
||||
let delta_args_2 = json!({
|
||||
"choices": [{
|
||||
"delta": {
|
||||
"tool_calls": [{
|
||||
"id": "call_a",
|
||||
"index": 0,
|
||||
"function": { "arguments": "1}" }
|
||||
}]
|
||||
}
|
||||
@@ -421,7 +545,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let body = build_body(&[delta_name, delta_args_1, delta_args_2, finish]);
|
||||
let body = build_body(&[delta_with_id, delta_without_id, finish]);
|
||||
let events = collect_events(&body).await;
|
||||
assert_matches!(
|
||||
&events[..],
|
||||
@@ -432,6 +556,47 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn preserves_tool_call_name_when_empty_deltas_arrive() {
|
||||
let delta_with_name = json!({
|
||||
"choices": [{
|
||||
"delta": {
|
||||
"tool_calls": [{
|
||||
"id": "call_a",
|
||||
"function": { "name": "do_a" }
|
||||
}]
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
||||
let delta_with_empty_name = json!({
|
||||
"choices": [{
|
||||
"delta": {
|
||||
"tool_calls": [{
|
||||
"id": "call_a",
|
||||
"function": { "name": "", "arguments": "{}" }
|
||||
}]
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
||||
let finish = json!({
|
||||
"choices": [{
|
||||
"finish_reason": "tool_calls"
|
||||
}]
|
||||
});
|
||||
|
||||
let body = build_body(&[delta_with_name, delta_with_empty_name, finish]);
|
||||
let events = collect_events(&body).await;
|
||||
assert_matches!(
|
||||
&events[..],
|
||||
[
|
||||
ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { name, arguments, .. }),
|
||||
ResponseEvent::Completed { .. }
|
||||
] if name == "do_a" && arguments == "{}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn emits_tool_calls_even_when_content_and_reasoning_present() {
|
||||
let delta_content_and_tools = json!({
|
||||
|
||||
111
codex-rs/codex-api/tests/models_integration.rs
Normal file
111
codex-rs/codex-api/tests/models_integration.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
use codex_api::AuthProvider;
|
||||
use codex_api::ModelsClient;
|
||||
use codex_api::provider::Provider;
|
||||
use codex_api::provider::RetryConfig;
|
||||
use codex_api::provider::WireApi;
|
||||
use codex_client::ReqwestTransport;
|
||||
use codex_protocol::openai_models::ClientVersion;
|
||||
use codex_protocol::openai_models::ConfigShellToolType;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::openai_models::ModelVisibility;
|
||||
use codex_protocol::openai_models::ModelsResponse;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::openai_models::ReasoningEffortPreset;
|
||||
use http::HeaderMap;
|
||||
use http::Method;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct DummyAuth;
|
||||
|
||||
impl AuthProvider for DummyAuth {
|
||||
fn bearer_token(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn provider(base_url: &str) -> Provider {
|
||||
Provider {
|
||||
name: "test".to_string(),
|
||||
base_url: base_url.to_string(),
|
||||
query_params: None,
|
||||
wire: WireApi::Responses,
|
||||
headers: HeaderMap::new(),
|
||||
retry: RetryConfig {
|
||||
max_attempts: 1,
|
||||
base_delay: std::time::Duration::from_millis(1),
|
||||
retry_429: false,
|
||||
retry_5xx: true,
|
||||
retry_transport: true,
|
||||
},
|
||||
stream_idle_timeout: std::time::Duration::from_secs(1),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn models_client_hits_models_endpoint() {
|
||||
let server = MockServer::start().await;
|
||||
let base_url = format!("{}/api/codex", server.uri());
|
||||
|
||||
let response = ModelsResponse {
|
||||
models: vec![ModelInfo {
|
||||
slug: "gpt-test".to_string(),
|
||||
display_name: "gpt-test".to_string(),
|
||||
description: Some("desc".to_string()),
|
||||
default_reasoning_level: ReasoningEffort::Medium,
|
||||
supported_reasoning_levels: vec![
|
||||
ReasoningEffortPreset {
|
||||
effort: ReasoningEffort::Low,
|
||||
description: ReasoningEffort::Low.to_string(),
|
||||
},
|
||||
ReasoningEffortPreset {
|
||||
effort: ReasoningEffort::Medium,
|
||||
description: ReasoningEffort::Medium.to_string(),
|
||||
},
|
||||
ReasoningEffortPreset {
|
||||
effort: ReasoningEffort::High,
|
||||
description: ReasoningEffort::High.to_string(),
|
||||
},
|
||||
],
|
||||
shell_type: ConfigShellToolType::ShellCommand,
|
||||
visibility: ModelVisibility::List,
|
||||
minimal_client_version: ClientVersion(0, 1, 0),
|
||||
supported_in_api: true,
|
||||
priority: 1,
|
||||
upgrade: None,
|
||||
}],
|
||||
};
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/models"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "application/json")
|
||||
.set_body_json(&response),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let transport = ReqwestTransport::new(reqwest::Client::new());
|
||||
let client = ModelsClient::new(transport, provider(&base_url), DummyAuth);
|
||||
|
||||
let result = client
|
||||
.list_models("0.1.0", HeaderMap::new())
|
||||
.await
|
||||
.expect("models request should succeed");
|
||||
|
||||
assert_eq!(result.models.len(), 1);
|
||||
assert_eq!(result.models[0].slug, "gpt-test");
|
||||
|
||||
let received = server
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("should capture requests");
|
||||
assert_eq!(received.len(), 1);
|
||||
assert_eq!(received[0].method, Method::GET.as_str());
|
||||
assert_eq!(received[0].url.path(), "/api/codex/models");
|
||||
}
|
||||
@@ -9,12 +9,10 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
clap = { workspace = true, features = ["derive", "wrap_help"], optional = true }
|
||||
codex-app-server-protocol = { workspace = true }
|
||||
codex-core = { workspace = true }
|
||||
codex-lmstudio = { workspace = true }
|
||||
codex-ollama = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
once_cell = { workspace = true }
|
||||
serde = { workspace = true, optional = true }
|
||||
toml = { workspace = true, optional = true }
|
||||
|
||||
|
||||
@@ -12,15 +12,14 @@ pub fn create_config_summary_entries(config: &Config) -> Vec<(&'static str, Stri
|
||||
("approval", config.approval_policy.to_string()),
|
||||
("sandbox", summarize_sandbox_policy(&config.sandbox_policy)),
|
||||
];
|
||||
if config.model_provider.wire_api == WireApi::Responses
|
||||
&& config.model_family.supports_reasoning_summaries
|
||||
{
|
||||
if config.model_provider.wire_api == WireApi::Responses {
|
||||
let reasoning_effort = config
|
||||
.model_reasoning_effort
|
||||
.or(config.model_family.default_reasoning_effort)
|
||||
.map(|effort| effort.to_string())
|
||||
.unwrap_or_else(|| "none".to_string());
|
||||
entries.push(("reasoning effort", reasoning_effort));
|
||||
.map(|effort| effort.to_string());
|
||||
entries.push((
|
||||
"reasoning effort",
|
||||
reasoning_effort.unwrap_or_else(|| "none".to_string()),
|
||||
));
|
||||
entries.push((
|
||||
"reasoning summaries",
|
||||
config.model_reasoning_summary.to_string(),
|
||||
|
||||
@@ -32,8 +32,6 @@ mod config_summary;
|
||||
pub use config_summary::create_config_summary_entries;
|
||||
// Shared fuzzy matcher (used by TUI selection popups and other UI filtering)
|
||||
pub mod fuzzy_match;
|
||||
// Shared model presets used by TUI and MCP server
|
||||
pub mod model_presets;
|
||||
// Shared approval presets (AskForApproval + Sandbox) used by TUI and MCP server
|
||||
// Not to be confused with AskForApproval, which we should probably rename to EscalationPolicy.
|
||||
pub mod approval_presets;
|
||||
|
||||
@@ -90,6 +90,7 @@ wildmatch = { workspace = true }
|
||||
|
||||
[features]
|
||||
deterministic_process_ids = []
|
||||
test-support = []
|
||||
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
|
||||
@@ -70,7 +70,9 @@ pub(crate) async fn apply_patch(
|
||||
)
|
||||
.await;
|
||||
match rx_approve.await.unwrap_or_default() {
|
||||
ReviewDecision::Approved | ReviewDecision::ApprovedForSession => {
|
||||
ReviewDecision::Approved
|
||||
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
|
||||
| ReviewDecision::ApprovedForSession => {
|
||||
InternalApplyPatchInvocation::DelegateToExec(ApplyPatchExec {
|
||||
action,
|
||||
user_explicitly_approved_this_action: true,
|
||||
|
||||
@@ -227,23 +227,6 @@ impl CodexAuth {
|
||||
})
|
||||
}
|
||||
|
||||
/// Raw plan string from the ID token (including unknown/new plan types).
|
||||
pub fn raw_plan_type(&self) -> Option<String> {
|
||||
self.get_plan_type().map(|plan| match plan {
|
||||
InternalPlanType::Known(k) => format!("{k:?}"),
|
||||
InternalPlanType::Unknown(raw) => raw,
|
||||
})
|
||||
}
|
||||
|
||||
/// Raw internal plan value from the ID token.
|
||||
/// Exposes the underlying `token_data::PlanType` without mapping it to the
|
||||
/// public `AccountPlanType`. Use this when downstream code needs to inspect
|
||||
/// internal/unknown plan strings exactly as issued in the token.
|
||||
pub(crate) fn get_plan_type(&self) -> Option<InternalPlanType> {
|
||||
self.get_current_token_data()
|
||||
.and_then(|t| t.id_token.chatgpt_plan_type)
|
||||
}
|
||||
|
||||
fn get_current_auth_json(&self) -> Option<AuthDotJson> {
|
||||
#[expect(clippy::unwrap_used)]
|
||||
self.auth_dot_json.lock().unwrap().clone()
|
||||
@@ -1041,10 +1024,6 @@ mod tests {
|
||||
.expect("auth available");
|
||||
|
||||
pretty_assertions::assert_eq!(auth.account_plan_type(), Some(AccountPlanType::Pro));
|
||||
pretty_assertions::assert_eq!(
|
||||
auth.get_plan_type(),
|
||||
Some(InternalPlanType::Known(InternalKnownPlan::Pro))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1065,10 +1044,6 @@ mod tests {
|
||||
.expect("auth available");
|
||||
|
||||
pretty_assertions::assert_eq!(auth.account_plan_type(), Some(AccountPlanType::Unknown));
|
||||
pretty_assertions::assert_eq!(
|
||||
auth.get_plan_type(),
|
||||
Some(InternalPlanType::Unknown("mystery-tier".to_string()))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1201,4 +1176,8 @@ impl AuthManager {
|
||||
self.reload();
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
pub fn get_auth_mode(&self) -> Option<AuthMode> {
|
||||
self.auth().map(|a| a.mode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,9 +20,9 @@ use codex_api::error::ApiError;
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use codex_otel::otel_event_manager::OtelEventManager;
|
||||
use codex_protocol::ConversationId;
|
||||
use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig;
|
||||
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use eventsource_stream::Event;
|
||||
use eventsource_stream::EventStreamError;
|
||||
@@ -46,10 +46,10 @@ use crate::default_client::build_reqwest_client;
|
||||
use crate::error::CodexErr;
|
||||
use crate::error::Result;
|
||||
use crate::flags::CODEX_RS_SSE_FIXTURE;
|
||||
use crate::model_family::ModelFamily;
|
||||
use crate::model_provider_info::ModelProviderInfo;
|
||||
use crate::model_provider_info::WireApi;
|
||||
use crate::openai_model_info::get_model_info;
|
||||
use crate::openai_models::model_family::ModelFamily;
|
||||
use crate::tools::spec::create_tools_json_for_chat_completions_api;
|
||||
use crate::tools::spec::create_tools_json_for_responses_api;
|
||||
|
||||
@@ -57,6 +57,7 @@ use crate::tools::spec::create_tools_json_for_responses_api;
|
||||
pub struct ModelClient {
|
||||
config: Arc<Config>,
|
||||
auth_manager: Option<Arc<AuthManager>>,
|
||||
model_family: ModelFamily,
|
||||
otel_event_manager: OtelEventManager,
|
||||
provider: ModelProviderInfo,
|
||||
conversation_id: ConversationId,
|
||||
@@ -70,6 +71,7 @@ impl ModelClient {
|
||||
pub fn new(
|
||||
config: Arc<Config>,
|
||||
auth_manager: Option<Arc<AuthManager>>,
|
||||
model_family: ModelFamily,
|
||||
otel_event_manager: OtelEventManager,
|
||||
provider: ModelProviderInfo,
|
||||
effort: Option<ReasoningEffortConfig>,
|
||||
@@ -80,6 +82,7 @@ impl ModelClient {
|
||||
Self {
|
||||
config,
|
||||
auth_manager,
|
||||
model_family,
|
||||
otel_event_manager,
|
||||
provider,
|
||||
conversation_id,
|
||||
@@ -90,16 +93,18 @@ impl ModelClient {
|
||||
}
|
||||
|
||||
pub fn get_model_context_window(&self) -> Option<i64> {
|
||||
let pct = self.config.model_family.effective_context_window_percent;
|
||||
let model_family = self.get_model_family();
|
||||
let effective_context_window_percent = model_family.effective_context_window_percent;
|
||||
self.config
|
||||
.model_context_window
|
||||
.or_else(|| get_model_info(&self.config.model_family).map(|info| info.context_window))
|
||||
.map(|w| w.saturating_mul(pct) / 100)
|
||||
.or_else(|| get_model_info(&model_family).map(|info| info.context_window))
|
||||
.map(|w| w.saturating_mul(effective_context_window_percent) / 100)
|
||||
}
|
||||
|
||||
pub fn get_auto_compact_token_limit(&self) -> Option<i64> {
|
||||
let model_family = self.get_model_family();
|
||||
self.config.model_auto_compact_token_limit.or_else(|| {
|
||||
get_model_info(&self.config.model_family).and_then(|info| info.auto_compact_token_limit)
|
||||
get_model_info(&model_family).and_then(|info| info.auto_compact_token_limit)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -149,9 +154,8 @@ impl ModelClient {
|
||||
}
|
||||
|
||||
let auth_manager = self.auth_manager.clone();
|
||||
let instructions = prompt
|
||||
.get_full_instructions(&self.config.model_family)
|
||||
.into_owned();
|
||||
let model_family = self.get_model_family();
|
||||
let instructions = prompt.get_full_instructions(&model_family).into_owned();
|
||||
let tools_json = create_tools_json_for_chat_completions_api(&prompt.tools)?;
|
||||
let api_prompt = build_api_prompt(prompt, instructions, tools_json);
|
||||
let conversation_id = self.conversation_id.to_string();
|
||||
@@ -204,16 +208,13 @@ impl ModelClient {
|
||||
}
|
||||
|
||||
let auth_manager = self.auth_manager.clone();
|
||||
let instructions = prompt
|
||||
.get_full_instructions(&self.config.model_family)
|
||||
.into_owned();
|
||||
let model_family = self.get_model_family();
|
||||
let instructions = prompt.get_full_instructions(&model_family).into_owned();
|
||||
let tools_json: Vec<Value> = create_tools_json_for_responses_api(&prompt.tools)?;
|
||||
|
||||
let reasoning = if self.config.model_family.supports_reasoning_summaries {
|
||||
let reasoning = if model_family.supports_reasoning_summaries {
|
||||
Some(Reasoning {
|
||||
effort: self
|
||||
.effort
|
||||
.or(self.config.model_family.default_reasoning_effort),
|
||||
effort: self.effort.or(model_family.default_reasoning_effort),
|
||||
summary: Some(self.summary),
|
||||
})
|
||||
} else {
|
||||
@@ -226,15 +227,15 @@ impl ModelClient {
|
||||
vec![]
|
||||
};
|
||||
|
||||
let verbosity = if self.config.model_family.support_verbosity {
|
||||
let verbosity = if model_family.support_verbosity {
|
||||
self.config
|
||||
.model_verbosity
|
||||
.or(self.config.model_family.default_verbosity)
|
||||
.or(model_family.default_verbosity)
|
||||
} else {
|
||||
if self.config.model_verbosity.is_some() {
|
||||
warn!(
|
||||
"model_verbosity is set but ignored as the model does not support verbosity: {}",
|
||||
self.config.model_family.family
|
||||
model_family.family
|
||||
);
|
||||
}
|
||||
None
|
||||
@@ -305,7 +306,7 @@ impl ModelClient {
|
||||
|
||||
/// Returns the currently configured model family.
|
||||
pub fn get_model_family(&self) -> ModelFamily {
|
||||
self.config.model_family.clone()
|
||||
self.model_family.clone()
|
||||
}
|
||||
|
||||
/// Returns the current reasoning effort setting.
|
||||
@@ -342,7 +343,7 @@ impl ModelClient {
|
||||
.with_telemetry(Some(request_telemetry));
|
||||
|
||||
let instructions = prompt
|
||||
.get_full_instructions(&self.config.model_family)
|
||||
.get_full_instructions(&self.get_model_family())
|
||||
.into_owned();
|
||||
let payload = ApiCompactionInput {
|
||||
model: &self.config.model,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::client_common::tools::ToolSpec;
|
||||
use crate::error::Result;
|
||||
use crate::model_family::ModelFamily;
|
||||
use crate::openai_models::model_family::ModelFamily;
|
||||
pub use codex_api::common::ResponseEvent;
|
||||
use codex_apply_patch::APPLY_PATCH_TOOL_INSTRUCTIONS;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
@@ -252,7 +252,7 @@ impl Stream for ResponseStream {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::model_family::find_family_for_model;
|
||||
use crate::openai_models::model_family::find_family_for_model;
|
||||
use codex_api::ResponsesApiRequest;
|
||||
use codex_api::common::OpenAiVerbosity;
|
||||
use codex_api::common::TextControls;
|
||||
@@ -309,7 +309,7 @@ mod tests {
|
||||
},
|
||||
];
|
||||
for test_case in test_cases {
|
||||
let model_family = find_family_for_model(test_case.slug).expect("known model slug");
|
||||
let model_family = find_family_for_model(test_case.slug);
|
||||
let expected = if test_case.expects_apply_patch_instructions {
|
||||
format!(
|
||||
"{}\n{}",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,7 @@ use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::config::Config;
|
||||
use crate::error::CodexErr;
|
||||
use crate::openai_models::models_manager::ModelsManager;
|
||||
use codex_protocol::protocol::InitialHistory;
|
||||
|
||||
/// Start an interactive sub-Codex conversation and return IO channels.
|
||||
@@ -35,6 +36,7 @@ use codex_protocol::protocol::InitialHistory;
|
||||
pub(crate) async fn run_codex_conversation_interactive(
|
||||
config: Config,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
models_manager: Arc<ModelsManager>,
|
||||
parent_session: Arc<Session>,
|
||||
parent_ctx: Arc<TurnContext>,
|
||||
cancel_token: CancellationToken,
|
||||
@@ -46,6 +48,7 @@ pub(crate) async fn run_codex_conversation_interactive(
|
||||
let CodexSpawnOk { codex, .. } = Codex::spawn(
|
||||
config,
|
||||
auth_manager,
|
||||
models_manager,
|
||||
initial_history.unwrap_or(InitialHistory::New),
|
||||
SessionSource::SubAgent(SubAgentSource::Review),
|
||||
)
|
||||
@@ -88,9 +91,11 @@ pub(crate) async fn run_codex_conversation_interactive(
|
||||
/// Convenience wrapper for one-time use with an initial prompt.
|
||||
///
|
||||
/// Internally calls the interactive variant, then immediately submits the provided input.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn run_codex_conversation_one_shot(
|
||||
config: Config,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
models_manager: Arc<ModelsManager>,
|
||||
input: Vec<UserInput>,
|
||||
parent_session: Arc<Session>,
|
||||
parent_ctx: Arc<TurnContext>,
|
||||
@@ -103,6 +108,7 @@ pub(crate) async fn run_codex_conversation_one_shot(
|
||||
let io = run_codex_conversation_interactive(
|
||||
config,
|
||||
auth_manager,
|
||||
models_manager,
|
||||
parent_session,
|
||||
parent_ctx,
|
||||
child_cancel.clone(),
|
||||
@@ -275,6 +281,7 @@ async fn handle_exec_approval(
|
||||
event.cwd,
|
||||
event.reason,
|
||||
event.risk,
|
||||
event.proposed_execpolicy_amendment,
|
||||
);
|
||||
let decision = await_approval_with_cancel(
|
||||
approval_fut,
|
||||
|
||||
@@ -32,13 +32,13 @@ pub const SUMMARIZATION_PROMPT: &str = include_str!("../templates/compact/prompt
|
||||
pub const SUMMARY_PREFIX: &str = include_str!("../templates/compact/summary_prefix.md");
|
||||
const COMPACT_USER_MESSAGE_MAX_TOKENS: usize = 20_000;
|
||||
|
||||
pub(crate) async fn should_use_remote_compact_task(session: &Session) -> bool {
|
||||
pub(crate) fn should_use_remote_compact_task(session: &Session) -> bool {
|
||||
session
|
||||
.services
|
||||
.auth_manager
|
||||
.auth()
|
||||
.is_some_and(|auth| auth.mode == AuthMode::ChatGPT)
|
||||
&& session.enabled(Feature::RemoteCompaction).await
|
||||
&& session.enabled(Feature::RemoteCompaction)
|
||||
}
|
||||
|
||||
pub(crate) async fn run_inline_auto_compact_task(
|
||||
|
||||
@@ -2,8 +2,8 @@ use crate::config::CONFIG_TOML_FILE;
|
||||
use crate::config::types::McpServerConfig;
|
||||
use crate::config::types::Notice;
|
||||
use anyhow::Context;
|
||||
use codex_protocol::config_types::ReasoningEffort;
|
||||
use codex_protocol::config_types::TrustLevel;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
@@ -574,7 +574,7 @@ impl ConfigEditsBuilder {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::types::McpServerTransportConfig;
|
||||
use codex_protocol::config_types::ReasoningEffort;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::tempdir;
|
||||
use tokio::runtime::Builder;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user