mirror of
https://github.com/openai/codex.git
synced 2026-04-06 13:54:49 +00:00
Compare commits
15 Commits
pr16734
...
codex/func
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7c4fa6ab3 | ||
|
|
b5edeb98a0 | ||
|
|
152b676597 | ||
|
|
4fd5c35c4f | ||
|
|
cca36c5681 | ||
|
|
9e19004bc2 | ||
|
|
39097ab65d | ||
|
|
3a22e10172 | ||
|
|
c9e706f8b6 | ||
|
|
8a19dbb177 | ||
|
|
6edb865cc6 | ||
|
|
13d828d236 | ||
|
|
e4f1b3a65e | ||
|
|
91ca49e53c | ||
|
|
8d19646861 |
1
.bazelrc
1
.bazelrc
@@ -124,7 +124,6 @@ build:argument-comment-lint --@rules_rust//rust/toolchain/channel=nightly
|
||||
common:ci-windows --config=ci-bazel
|
||||
common:ci-windows --build_metadata=TAG_os=windows
|
||||
common:ci-windows --repo_contents_cache=D:/a/.cache/bazel-repo-contents-cache
|
||||
common:ci-windows --repository_cache=D:/a/.cache/bazel-repo-cache
|
||||
|
||||
# We prefer to run the build actions entirely remotely so we can dial up the concurrency.
|
||||
# We have platform-specific tests, so we want to execute the tests on all platforms using the strongest sandboxing available on each platform.
|
||||
|
||||
31
.github/actions/setup-bazel-ci/action.yml
vendored
31
.github/actions/setup-bazel-ci/action.yml
vendored
@@ -9,9 +9,9 @@ inputs:
|
||||
required: false
|
||||
default: "false"
|
||||
outputs:
|
||||
cache-hit:
|
||||
description: Whether the Bazel repository cache key was restored exactly.
|
||||
value: ${{ steps.cache_bazel_repository_restore.outputs.cache-hit }}
|
||||
repository-cache-path:
|
||||
description: Filesystem path used for the Bazel repository cache.
|
||||
value: ${{ steps.configure_bazel_repository_cache.outputs.repository-cache-path }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
@@ -41,17 +41,16 @@ runs:
|
||||
- name: Set up Bazel
|
||||
uses: bazelbuild/setup-bazelisk@v3
|
||||
|
||||
# Restore bazel repository cache so we don't have to redownload all the external dependencies
|
||||
# on every CI run.
|
||||
- name: Restore bazel repository cache
|
||||
id: cache_bazel_repository_restore
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cache/bazel-repo-cache
|
||||
key: bazel-cache-${{ inputs.target }}-${{ hashFiles('MODULE.bazel', 'codex-rs/Cargo.lock', 'codex-rs/Cargo.toml') }}
|
||||
restore-keys: |
|
||||
bazel-cache-${{ inputs.target }}
|
||||
- name: Configure Bazel repository cache
|
||||
id: configure_bazel_repository_cache
|
||||
shell: pwsh
|
||||
run: |
|
||||
# Keep the repository cache under HOME on all runners. Windows `D:\a`
|
||||
# cache paths match `.bazelrc`, but `actions/cache/restore` currently
|
||||
# returns HTTP 400 for that path in the Windows clippy job.
|
||||
$repositoryCachePath = Join-Path $HOME '.cache/bazel-repo-cache'
|
||||
"repository-cache-path=$repositoryCachePath" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
|
||||
"BAZEL_REPOSITORY_CACHE=$repositoryCachePath" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
|
||||
- name: Configure Bazel output root (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
@@ -65,10 +64,6 @@ runs:
|
||||
$repoContentsCache = Join-Path $env:RUNNER_TEMP "bazel-repo-contents-cache-$env:GITHUB_RUN_ID-$env:GITHUB_JOB"
|
||||
"BAZEL_OUTPUT_USER_ROOT=$bazelOutputUserRoot" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
"BAZEL_REPO_CONTENTS_CACHE=$repoContentsCache" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
if (-not $hasDDrive) {
|
||||
$repositoryCache = Join-Path $env:USERPROFILE '.cache\bazel-repo-cache'
|
||||
"BAZEL_REPOSITORY_CACHE=$repositoryCache" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
}
|
||||
|
||||
- name: Expose MSVC SDK environment (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
|
||||
54
.github/workflows/bazel.yml
vendored
54
.github/workflows/bazel.yml
vendored
@@ -58,6 +58,20 @@ jobs:
|
||||
target: ${{ matrix.target }}
|
||||
install-test-prereqs: "true"
|
||||
|
||||
# Restore the Bazel repository cache explicitly so external dependencies
|
||||
# do not need to be re-downloaded on every CI run. Keep restore failures
|
||||
# non-fatal so transient cache-service errors degrade to a cold build
|
||||
# instead of failing the job.
|
||||
- name: Restore bazel repository cache
|
||||
id: cache_bazel_repository_restore
|
||||
continue-on-error: true
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: ${{ steps.setup_bazel.outputs.repository-cache-path }}
|
||||
key: bazel-cache-${{ matrix.target }}-${{ hashFiles('MODULE.bazel', 'codex-rs/Cargo.lock', 'codex-rs/Cargo.toml') }}
|
||||
restore-keys: |
|
||||
bazel-cache-${{ matrix.target }}
|
||||
|
||||
- name: Check MODULE.bazel.lock is up to date
|
||||
if: matrix.os == 'ubuntu-24.04' && matrix.target == 'x86_64-unknown-linux-gnu'
|
||||
shell: bash
|
||||
@@ -112,12 +126,11 @@ jobs:
|
||||
# Save bazel repository cache explicitly; make non-fatal so cache uploading
|
||||
# never fails the overall job. Only save when key wasn't hit.
|
||||
- name: Save bazel repository cache
|
||||
if: always() && !cancelled() && steps.setup_bazel.outputs.cache-hit != 'true'
|
||||
if: always() && !cancelled() && steps.cache_bazel_repository_restore.outputs.cache-hit != 'true'
|
||||
continue-on-error: true
|
||||
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
|
||||
with:
|
||||
path: |
|
||||
~/.cache/bazel-repo-cache
|
||||
path: ${{ steps.setup_bazel.outputs.repository-cache-path }}
|
||||
key: bazel-cache-${{ matrix.target }}-${{ hashFiles('MODULE.bazel', 'codex-rs/Cargo.lock', 'codex-rs/Cargo.toml') }}
|
||||
|
||||
clippy:
|
||||
@@ -148,6 +161,20 @@ jobs:
|
||||
with:
|
||||
target: ${{ matrix.target }}
|
||||
|
||||
# Restore the Bazel repository cache explicitly so external dependencies
|
||||
# do not need to be re-downloaded on every CI run. Keep restore failures
|
||||
# non-fatal so transient cache-service errors degrade to a cold build
|
||||
# instead of failing the job.
|
||||
- name: Restore bazel repository cache
|
||||
id: cache_bazel_repository_restore
|
||||
continue-on-error: true
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: ${{ steps.setup_bazel.outputs.repository-cache-path }}
|
||||
key: bazel-cache-${{ matrix.target }}-${{ hashFiles('MODULE.bazel', 'codex-rs/Cargo.lock', 'codex-rs/Cargo.toml') }}
|
||||
restore-keys: |
|
||||
bazel-cache-${{ matrix.target }}
|
||||
|
||||
- name: Set up Bazel execution logs
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -159,6 +186,18 @@ jobs:
|
||||
BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }}
|
||||
shell: bash
|
||||
run: |
|
||||
bazel_clippy_args=(
|
||||
--config=clippy
|
||||
--build_metadata=COMMIT_SHA=${GITHUB_SHA}
|
||||
--build_metadata=TAG_job=clippy
|
||||
)
|
||||
if [[ "${RUNNER_OS}" == "Windows" ]]; then
|
||||
# Some explicit targets pulled in through //codex-rs/... are
|
||||
# intentionally incompatible with `//:local_windows`, but the lint
|
||||
# aspect still traverses their compatible Rust deps.
|
||||
bazel_clippy_args+=(--skip_incompatible_explicit_targets)
|
||||
fi
|
||||
|
||||
bazel_target_lines="$(./scripts/list-bazel-clippy-targets.sh)"
|
||||
bazel_targets=()
|
||||
while IFS= read -r target; do
|
||||
@@ -168,9 +207,7 @@ jobs:
|
||||
./.github/scripts/run-bazel-ci.sh \
|
||||
-- \
|
||||
build \
|
||||
--config=clippy \
|
||||
--build_metadata=COMMIT_SHA=${GITHUB_SHA} \
|
||||
--build_metadata=TAG_job=clippy \
|
||||
"${bazel_clippy_args[@]}" \
|
||||
-- \
|
||||
"${bazel_targets[@]}"
|
||||
|
||||
@@ -186,10 +223,9 @@ jobs:
|
||||
# Save bazel repository cache explicitly; make non-fatal so cache uploading
|
||||
# never fails the overall job. Only save when key wasn't hit.
|
||||
- name: Save bazel repository cache
|
||||
if: always() && !cancelled() && steps.setup_bazel.outputs.cache-hit != 'true'
|
||||
if: always() && !cancelled() && steps.cache_bazel_repository_restore.outputs.cache-hit != 'true'
|
||||
continue-on-error: true
|
||||
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
|
||||
with:
|
||||
path: |
|
||||
~/.cache/bazel-repo-cache
|
||||
path: ${{ steps.setup_bazel.outputs.repository-cache-path }}
|
||||
key: bazel-cache-${{ matrix.target }}-${{ hashFiles('MODULE.bazel', 'codex-rs/Cargo.lock', 'codex-rs/Cargo.toml') }}
|
||||
|
||||
7
.github/workflows/rust-release.yml
vendored
7
.github/workflows/rust-release.yml
vendored
@@ -584,14 +584,11 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
with:
|
||||
node-version: 22
|
||||
# Node 24 bundles npm >= 11.5.1, which trusted publishing requires.
|
||||
node-version: 24
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
scope: "@openai"
|
||||
|
||||
# Trusted publishing requires npm CLI version 11.5.1 or later.
|
||||
- name: Update npm
|
||||
run: npm install -g npm@latest
|
||||
|
||||
- name: Download npm tarballs from release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
1
codex-rs/Cargo.lock
generated
1
codex-rs/Cargo.lock
generated
@@ -2552,6 +2552,7 @@ dependencies = [
|
||||
"codex-process-hardening",
|
||||
"ctor 0.6.3",
|
||||
"libc",
|
||||
"pretty_assertions",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -13,6 +13,7 @@ use crate::events::TrackEventRequest;
|
||||
use crate::events::codex_app_metadata;
|
||||
use crate::events::codex_plugin_metadata;
|
||||
use crate::events::codex_plugin_used_metadata;
|
||||
use crate::events::subagent_thread_started_event_request;
|
||||
use crate::facts::AnalyticsFact;
|
||||
use crate::facts::AppInvocation;
|
||||
use crate::facts::AppMentionedInput;
|
||||
@@ -24,6 +25,7 @@ use crate::facts::PluginStateChangedInput;
|
||||
use crate::facts::PluginUsedInput;
|
||||
use crate::facts::SkillInvocation;
|
||||
use crate::facts::SkillInvokedInput;
|
||||
use crate::facts::SubAgentThreadStartedInput;
|
||||
use crate::facts::TrackEventsContext;
|
||||
use crate::reducer::AnalyticsReducer;
|
||||
use crate::reducer::normalize_path_for_skill_id;
|
||||
@@ -47,6 +49,7 @@ use codex_plugin::AppConnectorId;
|
||||
use codex_plugin::PluginCapabilitySummary;
|
||||
use codex_plugin::PluginId;
|
||||
use codex_plugin::PluginTelemetryMetadata;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::collections::HashSet;
|
||||
@@ -446,6 +449,155 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize
|
||||
assert_eq!(payload[0]["event_params"]["parent_thread_id"], json!(null));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_thread_started_review_serializes_expected_shape() {
|
||||
let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request(
|
||||
SubAgentThreadStartedInput {
|
||||
thread_id: "thread-review".to_string(),
|
||||
product_client_id: "codex-tui".to_string(),
|
||||
client_name: "codex-tui".to_string(),
|
||||
client_version: "1.0.0".to_string(),
|
||||
model: "gpt-5".to_string(),
|
||||
ephemeral: false,
|
||||
subagent_source: SubAgentSource::Review,
|
||||
created_at: 123,
|
||||
},
|
||||
));
|
||||
|
||||
let payload = serde_json::to_value(&event).expect("serialize review subagent event");
|
||||
assert_eq!(payload["event_params"]["thread_source"], "subagent");
|
||||
assert_eq!(
|
||||
payload["event_params"]["app_server_client"]["product_client_id"],
|
||||
"codex-tui"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["event_params"]["app_server_client"]["client_name"],
|
||||
"codex-tui"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["event_params"]["app_server_client"]["client_version"],
|
||||
"1.0.0"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["event_params"]["app_server_client"]["rpc_transport"],
|
||||
"in_process"
|
||||
);
|
||||
assert_eq!(payload["event_params"]["created_at"], 123);
|
||||
assert_eq!(payload["event_params"]["initialization_mode"], "new");
|
||||
assert_eq!(payload["event_params"]["subagent_source"], "review");
|
||||
assert_eq!(payload["event_params"]["parent_thread_id"], json!(null));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_thread_started_thread_spawn_serializes_parent_thread_id() {
|
||||
let parent_thread_id =
|
||||
codex_protocol::ThreadId::from_string("11111111-1111-1111-1111-111111111111")
|
||||
.expect("valid thread id");
|
||||
let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request(
|
||||
SubAgentThreadStartedInput {
|
||||
thread_id: "thread-spawn".to_string(),
|
||||
product_client_id: "codex-tui".to_string(),
|
||||
client_name: "codex-tui".to_string(),
|
||||
client_version: "1.0.0".to_string(),
|
||||
model: "gpt-5".to_string(),
|
||||
ephemeral: true,
|
||||
subagent_source: SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth: 1,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
},
|
||||
created_at: 124,
|
||||
},
|
||||
));
|
||||
|
||||
let payload = serde_json::to_value(&event).expect("serialize thread spawn subagent event");
|
||||
assert_eq!(payload["event_params"]["thread_source"], "subagent");
|
||||
assert_eq!(payload["event_params"]["subagent_source"], "thread_spawn");
|
||||
assert_eq!(
|
||||
payload["event_params"]["parent_thread_id"],
|
||||
"11111111-1111-1111-1111-111111111111"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_thread_started_memory_consolidation_serializes_expected_shape() {
|
||||
let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request(
|
||||
SubAgentThreadStartedInput {
|
||||
thread_id: "thread-memory".to_string(),
|
||||
product_client_id: "codex-tui".to_string(),
|
||||
client_name: "codex-tui".to_string(),
|
||||
client_version: "1.0.0".to_string(),
|
||||
model: "gpt-5".to_string(),
|
||||
ephemeral: false,
|
||||
subagent_source: SubAgentSource::MemoryConsolidation,
|
||||
created_at: 125,
|
||||
},
|
||||
));
|
||||
|
||||
let payload =
|
||||
serde_json::to_value(&event).expect("serialize memory consolidation subagent event");
|
||||
assert_eq!(
|
||||
payload["event_params"]["subagent_source"],
|
||||
"memory_consolidation"
|
||||
);
|
||||
assert_eq!(payload["event_params"]["parent_thread_id"], json!(null));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_thread_started_other_serializes_expected_shape() {
|
||||
let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request(
|
||||
SubAgentThreadStartedInput {
|
||||
thread_id: "thread-guardian".to_string(),
|
||||
product_client_id: "codex-tui".to_string(),
|
||||
client_name: "codex-tui".to_string(),
|
||||
client_version: "1.0.0".to_string(),
|
||||
model: "gpt-5".to_string(),
|
||||
ephemeral: false,
|
||||
subagent_source: SubAgentSource::Other("guardian".to_string()),
|
||||
created_at: 126,
|
||||
},
|
||||
));
|
||||
|
||||
let payload = serde_json::to_value(&event).expect("serialize other subagent event");
|
||||
assert_eq!(payload["event_params"]["subagent_source"], "guardian");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subagent_thread_started_publishes_without_initialize() {
|
||||
let mut reducer = AnalyticsReducer::default();
|
||||
let mut events = Vec::new();
|
||||
|
||||
reducer
|
||||
.ingest(
|
||||
AnalyticsFact::Custom(CustomAnalyticsFact::SubAgentThreadStarted(
|
||||
SubAgentThreadStartedInput {
|
||||
thread_id: "thread-review".to_string(),
|
||||
product_client_id: "codex-tui".to_string(),
|
||||
client_name: "codex-tui".to_string(),
|
||||
client_version: "1.0.0".to_string(),
|
||||
model: "gpt-5".to_string(),
|
||||
ephemeral: false,
|
||||
subagent_source: SubAgentSource::Review,
|
||||
created_at: 127,
|
||||
},
|
||||
)),
|
||||
&mut events,
|
||||
)
|
||||
.await;
|
||||
|
||||
let payload = serde_json::to_value(&events).expect("serialize events");
|
||||
assert_eq!(payload.as_array().expect("events array").len(), 1);
|
||||
assert_eq!(payload[0]["event_type"], "codex_thread_initialized");
|
||||
assert_eq!(
|
||||
payload[0]["event_params"]["app_server_client"]["product_client_id"],
|
||||
"codex-tui"
|
||||
);
|
||||
assert_eq!(payload[0]["event_params"]["thread_source"], "subagent");
|
||||
assert_eq!(payload[0]["event_params"]["subagent_source"], "review");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_used_event_serializes_expected_shape() {
|
||||
let tracking = TrackEventsContext {
|
||||
|
||||
@@ -11,6 +11,7 @@ use crate::facts::PluginState;
|
||||
use crate::facts::PluginStateChangedInput;
|
||||
use crate::facts::SkillInvocation;
|
||||
use crate::facts::SkillInvokedInput;
|
||||
use crate::facts::SubAgentThreadStartedInput;
|
||||
use crate::facts::TrackEventsContext;
|
||||
use crate::reducer::AnalyticsReducer;
|
||||
use codex_app_server_protocol::ClientResponse;
|
||||
@@ -144,6 +145,12 @@ impl AnalyticsEventsClient {
|
||||
});
|
||||
}
|
||||
|
||||
pub fn track_subagent_thread_started(&self, input: SubAgentThreadStartedInput) {
|
||||
self.record_fact(AnalyticsFact::Custom(
|
||||
CustomAnalyticsFact::SubAgentThreadStarted(input),
|
||||
));
|
||||
}
|
||||
|
||||
pub fn track_app_mentioned(&self, tracking: TrackEventsContext, mentions: Vec<AppInvocation>) {
|
||||
if mentions.is_empty() {
|
||||
return;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
use crate::facts::AppInvocation;
|
||||
use crate::facts::InvocationType;
|
||||
use crate::facts::PluginState;
|
||||
use crate::facts::SubAgentThreadStartedInput;
|
||||
use crate::facts::TrackEventsContext;
|
||||
use codex_login::default_client::originator;
|
||||
use codex_plugin::PluginTelemetryMetadata;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize)]
|
||||
@@ -228,3 +230,49 @@ pub(crate) fn current_runtime_metadata() -> CodexRuntimeMetadata {
|
||||
runtime_arch: std::env::consts::ARCH.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn subagent_thread_started_event_request(
|
||||
input: SubAgentThreadStartedInput,
|
||||
) -> ThreadInitializedEvent {
|
||||
let event_params = ThreadInitializedEventParams {
|
||||
thread_id: input.thread_id,
|
||||
app_server_client: CodexAppServerClientMetadata {
|
||||
product_client_id: input.product_client_id,
|
||||
client_name: Some(input.client_name),
|
||||
client_version: Some(input.client_version),
|
||||
rpc_transport: AppServerRpcTransport::InProcess,
|
||||
experimental_api_enabled: None,
|
||||
},
|
||||
runtime: current_runtime_metadata(),
|
||||
model: input.model,
|
||||
ephemeral: input.ephemeral,
|
||||
thread_source: Some("subagent"),
|
||||
initialization_mode: ThreadInitializationMode::New,
|
||||
subagent_source: Some(subagent_source_name(&input.subagent_source)),
|
||||
parent_thread_id: subagent_parent_thread_id(&input.subagent_source),
|
||||
created_at: input.created_at,
|
||||
};
|
||||
ThreadInitializedEvent {
|
||||
event_type: "codex_thread_initialized",
|
||||
event_params,
|
||||
}
|
||||
}
|
||||
|
||||
fn subagent_source_name(subagent_source: &SubAgentSource) -> String {
|
||||
match subagent_source {
|
||||
SubAgentSource::Review => "review".to_string(),
|
||||
SubAgentSource::Compact => "compact".to_string(),
|
||||
SubAgentSource::ThreadSpawn { .. } => "thread_spawn".to_string(),
|
||||
SubAgentSource::MemoryConsolidation => "memory_consolidation".to_string(),
|
||||
SubAgentSource::Other(other) => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Option<String> {
|
||||
match subagent_source {
|
||||
SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id, ..
|
||||
} => Some(parent_thread_id.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_plugin::PluginTelemetryMetadata;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use serde::Serialize;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -50,6 +51,18 @@ pub struct AppInvocation {
|
||||
pub invocation_type: Option<InvocationType>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SubAgentThreadStartedInput {
|
||||
pub thread_id: String,
|
||||
pub product_client_id: String,
|
||||
pub client_name: String,
|
||||
pub client_version: String,
|
||||
pub model: String,
|
||||
pub ephemeral: bool,
|
||||
pub subagent_source: SubAgentSource,
|
||||
pub created_at: u64,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) enum AnalyticsFact {
|
||||
Initialize {
|
||||
@@ -75,6 +88,7 @@ pub(crate) enum AnalyticsFact {
|
||||
}
|
||||
|
||||
pub(crate) enum CustomAnalyticsFact {
|
||||
SubAgentThreadStarted(SubAgentThreadStartedInput),
|
||||
SkillInvoked(SkillInvokedInput),
|
||||
AppMentioned(AppMentionedInput),
|
||||
AppUsed(AppUsedInput),
|
||||
|
||||
@@ -8,6 +8,7 @@ pub use events::AppServerRpcTransport;
|
||||
pub use facts::AppInvocation;
|
||||
pub use facts::InvocationType;
|
||||
pub use facts::SkillInvocation;
|
||||
pub use facts::SubAgentThreadStartedInput;
|
||||
pub use facts::TrackEventsContext;
|
||||
pub use facts::build_track_events_context;
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ use crate::events::codex_app_metadata;
|
||||
use crate::events::codex_plugin_metadata;
|
||||
use crate::events::codex_plugin_used_metadata;
|
||||
use crate::events::plugin_state_event_type;
|
||||
use crate::events::subagent_thread_started_event_request;
|
||||
use crate::events::thread_source_name;
|
||||
use crate::facts::AnalyticsFact;
|
||||
use crate::facts::AppMentionedInput;
|
||||
@@ -24,6 +25,7 @@ use crate::facts::PluginState;
|
||||
use crate::facts::PluginStateChangedInput;
|
||||
use crate::facts::PluginUsedInput;
|
||||
use crate::facts::SkillInvokedInput;
|
||||
use crate::facts::SubAgentThreadStartedInput;
|
||||
use codex_app_server_protocol::ClientResponse;
|
||||
use codex_app_server_protocol::InitializeParams;
|
||||
use codex_git_utils::collect_git_info;
|
||||
@@ -76,6 +78,9 @@ impl AnalyticsReducer {
|
||||
}
|
||||
AnalyticsFact::Notification(_notification) => {}
|
||||
AnalyticsFact::Custom(input) => match input {
|
||||
CustomAnalyticsFact::SubAgentThreadStarted(input) => {
|
||||
self.ingest_subagent_thread_started(input, out);
|
||||
}
|
||||
CustomAnalyticsFact::SkillInvoked(input) => {
|
||||
self.ingest_skill_invoked(input, out).await;
|
||||
}
|
||||
@@ -120,6 +125,16 @@ impl AnalyticsReducer {
|
||||
);
|
||||
}
|
||||
|
||||
fn ingest_subagent_thread_started(
|
||||
&mut self,
|
||||
input: SubAgentThreadStartedInput,
|
||||
out: &mut Vec<TrackEventRequest>,
|
||||
) {
|
||||
out.push(TrackEventRequest::ThreadInitialized(
|
||||
subagent_thread_started_event_request(input),
|
||||
));
|
||||
}
|
||||
|
||||
async fn ingest_skill_invoked(
|
||||
&mut self,
|
||||
input: SkillInvokedInput,
|
||||
|
||||
@@ -691,6 +691,7 @@ impl CodexMessageProcessor {
|
||||
connection_id: ConnectionId,
|
||||
request: ClientRequest,
|
||||
app_server_client_name: Option<String>,
|
||||
app_server_client_version: Option<String>,
|
||||
request_context: RequestContext,
|
||||
) {
|
||||
let to_connection_request_id = |request_id| ConnectionRequestId {
|
||||
@@ -707,6 +708,8 @@ impl CodexMessageProcessor {
|
||||
self.thread_start(
|
||||
to_connection_request_id(request_id),
|
||||
params,
|
||||
app_server_client_name.clone(),
|
||||
app_server_client_version.clone(),
|
||||
request_context,
|
||||
)
|
||||
.await;
|
||||
@@ -811,6 +814,7 @@ impl CodexMessageProcessor {
|
||||
to_connection_request_id(request_id),
|
||||
params,
|
||||
app_server_client_name.clone(),
|
||||
app_server_client_version.clone(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -2057,6 +2061,8 @@ impl CodexMessageProcessor {
|
||||
&self,
|
||||
request_id: ConnectionRequestId,
|
||||
params: ThreadStartParams,
|
||||
app_server_client_name: Option<String>,
|
||||
app_server_client_version: Option<String>,
|
||||
request_context: RequestContext,
|
||||
) {
|
||||
let ThreadStartParams {
|
||||
@@ -2112,6 +2118,8 @@ impl CodexMessageProcessor {
|
||||
runtime_feature_enablement,
|
||||
cloud_requirements,
|
||||
request_id,
|
||||
app_server_client_name,
|
||||
app_server_client_version,
|
||||
config,
|
||||
typesafe_overrides,
|
||||
dynamic_tools,
|
||||
@@ -2185,6 +2193,8 @@ impl CodexMessageProcessor {
|
||||
runtime_feature_enablement: BTreeMap<String, bool>,
|
||||
cloud_requirements: CloudRequirementsLoader,
|
||||
request_id: ConnectionRequestId,
|
||||
app_server_client_name: Option<String>,
|
||||
app_server_client_version: Option<String>,
|
||||
config_overrides: Option<HashMap<String, serde_json::Value>>,
|
||||
typesafe_overrides: ConfigOverrides,
|
||||
dynamic_tools: Option<Vec<ApiDynamicToolSpec>>,
|
||||
@@ -2331,6 +2341,19 @@ impl CodexMessageProcessor {
|
||||
session_configured,
|
||||
..
|
||||
} = new_conv;
|
||||
if let Err(error) = Self::set_app_server_client_info(
|
||||
thread.as_ref(),
|
||||
app_server_client_name,
|
||||
app_server_client_version,
|
||||
)
|
||||
.await
|
||||
{
|
||||
listener_task_context
|
||||
.outgoing
|
||||
.send_error(request_id, error)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
let config_snapshot = thread
|
||||
.config_snapshot()
|
||||
.instrument(tracing::info_span!(
|
||||
@@ -6474,6 +6497,7 @@ impl CodexMessageProcessor {
|
||||
request_id: ConnectionRequestId,
|
||||
params: TurnStartParams,
|
||||
app_server_client_name: Option<String>,
|
||||
app_server_client_version: Option<String>,
|
||||
) {
|
||||
if let Err(error) = Self::validate_v2_input_limit(¶ms.input) {
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
@@ -6486,8 +6510,12 @@ impl CodexMessageProcessor {
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Err(error) =
|
||||
Self::set_app_server_client_name(thread.as_ref(), app_server_client_name).await
|
||||
if let Err(error) = Self::set_app_server_client_info(
|
||||
thread.as_ref(),
|
||||
app_server_client_name,
|
||||
app_server_client_version,
|
||||
)
|
||||
.await
|
||||
{
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return;
|
||||
@@ -6581,16 +6609,17 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_app_server_client_name(
|
||||
async fn set_app_server_client_info(
|
||||
thread: &CodexThread,
|
||||
app_server_client_name: Option<String>,
|
||||
app_server_client_version: Option<String>,
|
||||
) -> Result<(), JSONRPCErrorError> {
|
||||
thread
|
||||
.set_app_server_client_name(app_server_client_name)
|
||||
.set_app_server_client_info(app_server_client_name, app_server_client_version)
|
||||
.await
|
||||
.map_err(|err| JSONRPCErrorError {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: format!("failed to set app server client name: {err}"),
|
||||
message: format!("failed to set app server client info: {err}"),
|
||||
data: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -850,6 +850,7 @@ impl MessageProcessor {
|
||||
connection_id,
|
||||
other,
|
||||
session.app_server_client_name.clone(),
|
||||
session.client_version.clone(),
|
||||
request_context,
|
||||
)
|
||||
.boxed()
|
||||
|
||||
@@ -563,7 +563,7 @@ fn create_config_toml_with_chatgpt_base_url(
|
||||
general_analytics_enabled: bool,
|
||||
) -> std::io::Result<()> {
|
||||
let general_analytics_toml = if general_analytics_enabled {
|
||||
"\n[features]\ngeneral_analytics = true\n".to_string()
|
||||
"\ngeneral_analytics = true".to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
@@ -578,6 +578,8 @@ sandbox_mode = "read-only"
|
||||
chatgpt_base_url = "{chatgpt_base_url}"
|
||||
|
||||
model_provider = "mock_provider"
|
||||
|
||||
[features]
|
||||
{general_analytics_toml}
|
||||
|
||||
[model_providers.mock_provider]
|
||||
|
||||
@@ -827,7 +827,7 @@ fn create_config_toml_with_chatgpt_base_url(
|
||||
general_analytics_enabled: bool,
|
||||
) -> std::io::Result<()> {
|
||||
let general_analytics_toml = if general_analytics_enabled {
|
||||
"\n[features]\ngeneral_analytics = true\n".to_string()
|
||||
"\ngeneral_analytics = true".to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
@@ -842,6 +842,8 @@ sandbox_mode = "read-only"
|
||||
chatgpt_base_url = "{chatgpt_base_url}"
|
||||
|
||||
model_provider = "mock_provider"
|
||||
|
||||
[features]
|
||||
{general_analytics_toml}
|
||||
|
||||
[model_providers.mock_provider]
|
||||
|
||||
@@ -51,6 +51,8 @@ use codex_core::config::find_codex_home;
|
||||
use codex_features::FEATURES;
|
||||
use codex_features::Stage;
|
||||
use codex_features::is_known_feature_key;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_terminal_detection::TerminalName;
|
||||
|
||||
/// Codex CLI
|
||||
@@ -170,6 +172,9 @@ enum DebugSubcommand {
|
||||
/// Tooling: helps debug the app server.
|
||||
AppServer(DebugAppServerCommand),
|
||||
|
||||
/// Render the model-visible prompt input list as JSON.
|
||||
PromptInput(DebugPromptInputCommand),
|
||||
|
||||
/// Internal: reset local memory state for a fresh start.
|
||||
#[clap(hide = true)]
|
||||
ClearMemories,
|
||||
@@ -193,6 +198,17 @@ struct DebugAppServerSendMessageV2Command {
|
||||
user_message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct DebugPromptInputCommand {
|
||||
/// Optional user prompt to append after session context.
|
||||
#[arg(value_name = "PROMPT")]
|
||||
prompt: Option<String>,
|
||||
|
||||
/// Optional image(s) to attach to the user prompt.
|
||||
#[arg(long = "image", short = 'i', value_name = "FILE", value_delimiter = ',', num_args = 1..)]
|
||||
images: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct ResumeCommand {
|
||||
/// Conversation/session id (UUID) or thread name. UUIDs take precedence if it parses.
|
||||
@@ -915,6 +931,20 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
|
||||
)?;
|
||||
run_debug_app_server_command(cmd).await?;
|
||||
}
|
||||
DebugSubcommand::PromptInput(cmd) => {
|
||||
reject_remote_mode_for_subcommand(
|
||||
root_remote.as_deref(),
|
||||
root_remote_auth_token_env.as_deref(),
|
||||
"debug prompt-input",
|
||||
)?;
|
||||
run_debug_prompt_input_command(
|
||||
cmd,
|
||||
root_config_overrides,
|
||||
interactive,
|
||||
arg0_paths.clone(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
DebugSubcommand::ClearMemories => {
|
||||
reject_remote_mode_for_subcommand(
|
||||
root_remote.as_deref(),
|
||||
@@ -1083,6 +1113,72 @@ fn maybe_print_under_development_feature_warning(
|
||||
);
|
||||
}
|
||||
|
||||
async fn run_debug_prompt_input_command(
|
||||
cmd: DebugPromptInputCommand,
|
||||
root_config_overrides: CliConfigOverrides,
|
||||
interactive: TuiCli,
|
||||
arg0_paths: Arg0DispatchPaths,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut cli_kv_overrides = root_config_overrides
|
||||
.parse_overrides()
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
if interactive.web_search {
|
||||
cli_kv_overrides.push((
|
||||
"web_search".to_string(),
|
||||
toml::Value::String("live".to_string()),
|
||||
));
|
||||
}
|
||||
|
||||
let approval_policy = if interactive.full_auto {
|
||||
Some(AskForApproval::OnRequest)
|
||||
} else if interactive.dangerously_bypass_approvals_and_sandbox {
|
||||
Some(AskForApproval::Never)
|
||||
} else {
|
||||
interactive.approval_policy.map(Into::into)
|
||||
};
|
||||
let sandbox_mode = if interactive.full_auto {
|
||||
Some(codex_protocol::config_types::SandboxMode::WorkspaceWrite)
|
||||
} else if interactive.dangerously_bypass_approvals_and_sandbox {
|
||||
Some(codex_protocol::config_types::SandboxMode::DangerFullAccess)
|
||||
} else {
|
||||
interactive.sandbox_mode.map(Into::into)
|
||||
};
|
||||
let overrides = ConfigOverrides {
|
||||
model: interactive.model,
|
||||
config_profile: interactive.config_profile,
|
||||
approval_policy,
|
||||
sandbox_mode,
|
||||
cwd: interactive.cwd,
|
||||
codex_self_exe: arg0_paths.codex_self_exe,
|
||||
codex_linux_sandbox_exe: arg0_paths.codex_linux_sandbox_exe,
|
||||
main_execve_wrapper_exe: arg0_paths.main_execve_wrapper_exe,
|
||||
show_raw_agent_reasoning: interactive.oss.then_some(true),
|
||||
ephemeral: Some(true),
|
||||
additional_writable_roots: interactive.add_dir,
|
||||
..Default::default()
|
||||
};
|
||||
let config =
|
||||
Config::load_with_cli_overrides_and_harness_overrides(cli_kv_overrides, overrides).await?;
|
||||
|
||||
let mut input = interactive
|
||||
.images
|
||||
.into_iter()
|
||||
.chain(cmd.images)
|
||||
.map(|path| UserInput::LocalImage { path })
|
||||
.collect::<Vec<_>>();
|
||||
if let Some(prompt) = cmd.prompt.or(interactive.prompt) {
|
||||
input.push(UserInput::Text {
|
||||
text: prompt.replace("\r\n", "\n").replace('\r', "\n"),
|
||||
text_elements: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
let prompt_input = codex_core::prompt_debug::build_prompt_input(config, input).await?;
|
||||
println!("{}", serde_json::to_string_pretty(&prompt_input)?);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_debug_clear_memories_command(
|
||||
root_config_overrides: &CliConfigOverrides,
|
||||
interactive: &TuiCli,
|
||||
@@ -1489,6 +1585,32 @@ mod tests {
|
||||
app_server
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_prompt_input_parses_prompt_and_images() {
|
||||
let cli = MultitoolCli::try_parse_from([
|
||||
"codex",
|
||||
"debug",
|
||||
"prompt-input",
|
||||
"hello",
|
||||
"--image",
|
||||
"/tmp/a.png,/tmp/b.png",
|
||||
])
|
||||
.expect("parse");
|
||||
|
||||
let Some(Subcommand::Debug(DebugCommand {
|
||||
subcommand: DebugSubcommand::PromptInput(cmd),
|
||||
})) = cli.subcommand
|
||||
else {
|
||||
panic!("expected debug prompt-input subcommand");
|
||||
};
|
||||
|
||||
assert_eq!(cmd.prompt.as_deref(), Some("hello"));
|
||||
assert_eq!(
|
||||
cmd.images,
|
||||
vec![PathBuf::from("/tmp/a.png"), PathBuf::from("/tmp/b.png")]
|
||||
);
|
||||
}
|
||||
|
||||
fn sample_exit_info(conversation_id: Option<&str>, thread_name: Option<&str>) -> AppExitInfo {
|
||||
let token_usage = TokenUsage {
|
||||
output_tokens: 2,
|
||||
|
||||
@@ -265,7 +265,7 @@ fn append_code_mode_sample_for_definition(definition: &ToolDefinition) -> String
|
||||
CodeModeToolKind::Function => definition
|
||||
.input_schema
|
||||
.as_ref()
|
||||
.map(render_json_schema_to_typescript)
|
||||
.map(render_json_schema_to_typescript_with_property_descriptions)
|
||||
.unwrap_or_else(|| "unknown".to_string()),
|
||||
CodeModeToolKind::Freeform => "string".to_string(),
|
||||
};
|
||||
@@ -297,6 +297,33 @@ pub fn render_json_schema_to_typescript(schema: &JsonValue) -> String {
|
||||
render_json_schema_to_typescript_inner(schema)
|
||||
}
|
||||
|
||||
fn render_json_schema_to_typescript_with_property_descriptions(schema: &JsonValue) -> String {
|
||||
let Some(map) = schema.as_object() else {
|
||||
return render_json_schema_to_typescript(schema);
|
||||
};
|
||||
|
||||
if !(map.contains_key("properties")
|
||||
|| map.contains_key("additionalProperties")
|
||||
|| map.contains_key("required"))
|
||||
{
|
||||
return render_json_schema_to_typescript(schema);
|
||||
}
|
||||
|
||||
let Some(properties) = map.get("properties").and_then(JsonValue::as_object) else {
|
||||
return render_json_schema_to_typescript(schema);
|
||||
};
|
||||
if properties.values().all(|value| {
|
||||
value
|
||||
.get("description")
|
||||
.and_then(JsonValue::as_str)
|
||||
.is_none_or(str::is_empty)
|
||||
}) {
|
||||
return render_json_schema_to_typescript(schema);
|
||||
}
|
||||
|
||||
render_json_schema_object_with_property_descriptions(map)
|
||||
}
|
||||
|
||||
fn render_json_schema_to_typescript_inner(schema: &JsonValue) -> String {
|
||||
match schema {
|
||||
JsonValue::Bool(true) => "unknown".to_string(),
|
||||
@@ -460,6 +487,67 @@ fn render_json_schema_object(map: &serde_json::Map<String, JsonValue>) -> String
|
||||
format!("{{ {} }}", lines.join(" "))
|
||||
}
|
||||
|
||||
fn render_json_schema_object_with_property_descriptions(
|
||||
map: &serde_json::Map<String, JsonValue>,
|
||||
) -> String {
|
||||
let required = map
|
||||
.get("required")
|
||||
.and_then(JsonValue::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(JsonValue::as_str)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let properties = map
|
||||
.get("properties")
|
||||
.and_then(JsonValue::as_object)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut sorted_properties = properties.iter().collect::<Vec<_>>();
|
||||
sorted_properties.sort_unstable_by(|(name_a, _), (name_b, _)| name_a.cmp(name_b));
|
||||
let mut lines = vec!["{".to_string()];
|
||||
for (name, value) in sorted_properties {
|
||||
if let Some(description) = value.get("description").and_then(JsonValue::as_str) {
|
||||
for description_line in description
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
{
|
||||
lines.push(format!(" // {description_line}"));
|
||||
}
|
||||
}
|
||||
|
||||
let optional = if required.iter().any(|required_name| required_name == name) {
|
||||
""
|
||||
} else {
|
||||
"?"
|
||||
};
|
||||
let property_name = render_json_schema_property_name(name);
|
||||
let property_type = render_json_schema_to_typescript_inner(value);
|
||||
lines.push(format!(" {property_name}{optional}: {property_type};"));
|
||||
}
|
||||
|
||||
if let Some(additional_properties) = map.get("additionalProperties") {
|
||||
let property_type = match additional_properties {
|
||||
JsonValue::Bool(true) => Some("unknown".to_string()),
|
||||
JsonValue::Bool(false) => None,
|
||||
value => Some(render_json_schema_to_typescript_inner(value)),
|
||||
};
|
||||
|
||||
if let Some(property_type) = property_type {
|
||||
lines.push(format!(" [key: string]: {property_type};"));
|
||||
}
|
||||
} else if properties.is_empty() {
|
||||
lines.push(" [key: string]: unknown;".to_string());
|
||||
}
|
||||
|
||||
lines.push("}".to_string());
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn render_json_schema_property_name(name: &str) -> String {
|
||||
if normalize_code_mode_identifier(name) == name {
|
||||
name.to_string()
|
||||
@@ -548,6 +636,36 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn augment_tool_definition_includes_input_property_descriptions_as_comments() {
|
||||
let definition = ToolDefinition {
|
||||
name: "weather_tool".to_string(),
|
||||
description: "Weather tool".to_string(),
|
||||
kind: CodeModeToolKind::Function,
|
||||
input_schema: Some(json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"weather": {
|
||||
"type": "array",
|
||||
"description": "look up weather for a given list of locations",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": { "type": "string" }
|
||||
},
|
||||
"required": ["location"]
|
||||
}
|
||||
}
|
||||
}
|
||||
})),
|
||||
output_schema: None,
|
||||
};
|
||||
|
||||
let description = augment_tool_definition(definition).description;
|
||||
assert!(description.contains("// look up weather for a given list of locations"));
|
||||
assert!(description.contains("weather?: Array<{ location: string; }>;"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_mode_only_description_includes_nested_tools() {
|
||||
let description = build_exec_tool_description(
|
||||
|
||||
@@ -521,6 +521,15 @@
|
||||
"include_apply_patch_tool": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"include_apps_instructions": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"include_environment_context": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"include_permissions_instructions": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"js_repl_node_module_dirs": {
|
||||
"description": "Ordered list of directories to search for Node modules in `js_repl`.",
|
||||
"items": {
|
||||
@@ -2268,6 +2277,18 @@
|
||||
"default": null,
|
||||
"description": "Settings that govern if and what will be written to `~/.codex/history.jsonl`."
|
||||
},
|
||||
"include_apps_instructions": {
|
||||
"description": "Whether to inject the `<apps_instructions>` developer block.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"include_environment_context": {
|
||||
"description": "Whether to inject the `<environment_context>` user block.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"include_permissions_instructions": {
|
||||
"description": "Whether to inject the `<permissions instructions>` developer block.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"instructions": {
|
||||
"description": "System instructions.",
|
||||
"type": "string"
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::agent::registry::AgentRegistry;
|
||||
use crate::agent::role::DEFAULT_ROLE_NAME;
|
||||
use crate::agent::role::resolve_role_config;
|
||||
use crate::agent::status::is_final;
|
||||
use crate::codex::emit_subagent_session_started;
|
||||
use crate::codex_thread::ThreadConfigSnapshot;
|
||||
use crate::find_archived_thread_path_by_id_str;
|
||||
use crate::find_thread_path_by_id_str;
|
||||
@@ -245,6 +246,48 @@ impl AgentControl {
|
||||
agent_metadata.agent_id = Some(new_thread.thread_id);
|
||||
reservation.commit(agent_metadata.clone());
|
||||
|
||||
if let Some(SessionSource::SubAgent(
|
||||
subagent_source @ SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id, ..
|
||||
},
|
||||
)) = notification_source.as_ref()
|
||||
&& new_thread.thread.enabled(Feature::GeneralAnalytics)
|
||||
{
|
||||
let client_metadata = match state.get_thread(*parent_thread_id).await {
|
||||
Ok(parent_thread) => {
|
||||
parent_thread
|
||||
.codex
|
||||
.session
|
||||
.app_server_client_metadata()
|
||||
.await
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
error = %error,
|
||||
parent_thread_id = %parent_thread_id,
|
||||
"skipping subagent thread analytics: failed to load parent thread metadata"
|
||||
);
|
||||
crate::codex::AppServerClientMetadata {
|
||||
client_name: None,
|
||||
client_version: None,
|
||||
}
|
||||
}
|
||||
};
|
||||
let thread_config = new_thread.thread.codex.thread_config_snapshot().await;
|
||||
emit_subagent_session_started(
|
||||
&new_thread
|
||||
.thread
|
||||
.codex
|
||||
.session
|
||||
.services
|
||||
.analytics_events_client,
|
||||
client_metadata,
|
||||
new_thread.thread_id,
|
||||
thread_config,
|
||||
subagent_source.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
// Notify a new thread has been created. This notification will be processed by clients
|
||||
// to subscribe or drain this newly created thread.
|
||||
// TODO(jif) add helper for drain
|
||||
|
||||
@@ -28,6 +28,7 @@ use std::sync::Arc;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use codex_api::CompactClient as ApiCompactClient;
|
||||
@@ -119,6 +120,9 @@ use codex_response_debug_context::telemetry_transport_error_message;
|
||||
pub const OPENAI_BETA_HEADER: &str = "OpenAI-Beta";
|
||||
pub const X_CODEX_TURN_STATE_HEADER: &str = "x-codex-turn-state";
|
||||
pub const X_CODEX_TURN_METADATA_HEADER: &str = "x-codex-turn-metadata";
|
||||
pub const X_CODEX_PARENT_THREAD_ID_HEADER: &str = "x-codex-parent-thread-id";
|
||||
pub const X_CODEX_WINDOW_ID_HEADER: &str = "x-codex-window-id";
|
||||
pub const X_OPENAI_SUBAGENT_HEADER: &str = "x-openai-subagent";
|
||||
pub const X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER: &str =
|
||||
"x-responsesapi-include-timing-metrics";
|
||||
const RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE: &str = "responses_websockets=2026-02-06";
|
||||
@@ -137,6 +141,7 @@ pub(crate) const WEBSOCKET_CONNECT_TIMEOUT: Duration =
|
||||
struct ModelClientState {
|
||||
auth_manager: Option<Arc<AuthManager>>,
|
||||
conversation_id: ThreadId,
|
||||
window_generation: AtomicU64,
|
||||
provider: ModelProviderInfo,
|
||||
auth_env_telemetry: AuthEnvTelemetry,
|
||||
session_source: SessionSource,
|
||||
@@ -274,6 +279,7 @@ impl ModelClient {
|
||||
state: Arc::new(ModelClientState {
|
||||
auth_manager,
|
||||
conversation_id,
|
||||
window_generation: AtomicU64::new(0),
|
||||
provider,
|
||||
auth_env_telemetry,
|
||||
session_source,
|
||||
@@ -303,6 +309,24 @@ impl ModelClient {
|
||||
self.state.auth_manager.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn set_window_generation(&self, window_generation: u64) {
|
||||
self.state
|
||||
.window_generation
|
||||
.store(window_generation, Ordering::Relaxed);
|
||||
self.store_cached_websocket_session(WebsocketSession::default());
|
||||
}
|
||||
|
||||
pub(crate) fn advance_window_generation(&self) {
|
||||
self.state.window_generation.fetch_add(1, Ordering::Relaxed);
|
||||
self.store_cached_websocket_session(WebsocketSession::default());
|
||||
}
|
||||
|
||||
fn current_window_id(&self) -> String {
|
||||
let conversation_id = self.state.conversation_id;
|
||||
let window_generation = self.state.window_generation.load(Ordering::Relaxed);
|
||||
format!("{conversation_id}:{window_generation}")
|
||||
}
|
||||
|
||||
fn take_cached_websocket_session(&self) -> WebsocketSession {
|
||||
let mut cached_websocket_session = self
|
||||
.state
|
||||
@@ -401,7 +425,7 @@ impl ModelClient {
|
||||
text,
|
||||
};
|
||||
|
||||
let mut extra_headers = self.build_subagent_headers();
|
||||
let mut extra_headers = self.build_responses_identity_headers();
|
||||
extra_headers.extend(build_conversation_headers(Some(
|
||||
self.state.conversation_id.to_string(),
|
||||
)));
|
||||
@@ -461,21 +485,56 @@ impl ModelClient {
|
||||
|
||||
fn build_subagent_headers(&self) -> ApiHeaderMap {
|
||||
let mut extra_headers = ApiHeaderMap::new();
|
||||
if let SessionSource::SubAgent(sub) = &self.state.session_source {
|
||||
let subagent = match sub {
|
||||
SubAgentSource::Review => "review".to_string(),
|
||||
SubAgentSource::Compact => "compact".to_string(),
|
||||
SubAgentSource::MemoryConsolidation => "memory_consolidation".to_string(),
|
||||
SubAgentSource::ThreadSpawn { .. } => "collab_spawn".to_string(),
|
||||
SubAgentSource::Other(label) => label.clone(),
|
||||
};
|
||||
if let Ok(val) = HeaderValue::from_str(&subagent) {
|
||||
extra_headers.insert("x-openai-subagent", val);
|
||||
}
|
||||
if let Some(subagent) = subagent_header_value(&self.state.session_source)
|
||||
&& let Ok(val) = HeaderValue::from_str(&subagent)
|
||||
{
|
||||
extra_headers.insert(X_OPENAI_SUBAGENT_HEADER, val);
|
||||
}
|
||||
extra_headers
|
||||
}
|
||||
|
||||
fn build_responses_identity_headers(&self) -> ApiHeaderMap {
|
||||
let mut extra_headers = self.build_subagent_headers();
|
||||
if let Some(parent_thread_id) = parent_thread_id_header_value(&self.state.session_source)
|
||||
&& let Ok(val) = HeaderValue::from_str(&parent_thread_id)
|
||||
{
|
||||
extra_headers.insert(X_CODEX_PARENT_THREAD_ID_HEADER, val);
|
||||
}
|
||||
if let Ok(val) = HeaderValue::from_str(&self.current_window_id()) {
|
||||
extra_headers.insert(X_CODEX_WINDOW_ID_HEADER, val);
|
||||
}
|
||||
extra_headers
|
||||
}
|
||||
|
||||
fn build_ws_client_metadata(
|
||||
&self,
|
||||
turn_metadata_header: Option<&str>,
|
||||
) -> HashMap<String, String> {
|
||||
let mut client_metadata = HashMap::new();
|
||||
client_metadata.insert(
|
||||
X_CODEX_WINDOW_ID_HEADER.to_string(),
|
||||
self.current_window_id(),
|
||||
);
|
||||
if let Some(subagent) = subagent_header_value(&self.state.session_source) {
|
||||
client_metadata.insert(X_OPENAI_SUBAGENT_HEADER.to_string(), subagent);
|
||||
}
|
||||
if let Some(parent_thread_id) = parent_thread_id_header_value(&self.state.session_source) {
|
||||
client_metadata.insert(
|
||||
X_CODEX_PARENT_THREAD_ID_HEADER.to_string(),
|
||||
parent_thread_id,
|
||||
);
|
||||
}
|
||||
if let Some(turn_metadata_header) = parse_turn_metadata_header(turn_metadata_header)
|
||||
&& let Ok(turn_metadata) = turn_metadata_header.to_str()
|
||||
{
|
||||
client_metadata.insert(
|
||||
X_CODEX_TURN_METADATA_HEADER.to_string(),
|
||||
turn_metadata.to_string(),
|
||||
);
|
||||
}
|
||||
client_metadata
|
||||
}
|
||||
|
||||
/// Builds request telemetry for unary API calls (e.g., Compact endpoint).
|
||||
fn build_request_telemetry(
|
||||
session_telemetry: &SessionTelemetry,
|
||||
@@ -655,6 +714,7 @@ impl ModelClient {
|
||||
headers.insert("x-client-request-id", header_value);
|
||||
}
|
||||
headers.extend(build_conversation_headers(Some(conversation_id)));
|
||||
headers.extend(self.build_responses_identity_headers());
|
||||
headers.insert(
|
||||
OPENAI_BETA_HEADER,
|
||||
HeaderValue::from_static(RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE),
|
||||
@@ -678,7 +738,7 @@ impl Drop for ModelClientSession {
|
||||
}
|
||||
|
||||
impl ModelClientSession {
|
||||
fn reset_websocket_session(&mut self) {
|
||||
pub(crate) fn reset_websocket_session(&mut self) {
|
||||
self.websocket_session.connection = None;
|
||||
self.websocket_session.last_request = None;
|
||||
self.websocket_session.last_response_rx = None;
|
||||
@@ -769,11 +829,15 @@ impl ModelClientSession {
|
||||
ApiResponsesOptions {
|
||||
conversation_id: Some(conversation_id),
|
||||
session_source: Some(self.client.state.session_source.clone()),
|
||||
extra_headers: build_responses_headers(
|
||||
self.client.state.beta_features_header.as_deref(),
|
||||
Some(&self.turn_state),
|
||||
turn_metadata_header.as_ref(),
|
||||
),
|
||||
extra_headers: {
|
||||
let mut headers = build_responses_headers(
|
||||
self.client.state.beta_features_header.as_deref(),
|
||||
Some(&self.turn_state),
|
||||
turn_metadata_header.as_ref(),
|
||||
);
|
||||
headers.extend(self.client.build_responses_identity_headers());
|
||||
headers
|
||||
},
|
||||
compression,
|
||||
turn_state: Some(Arc::clone(&self.turn_state)),
|
||||
}
|
||||
@@ -1137,7 +1201,7 @@ impl ModelClientSession {
|
||||
)?;
|
||||
let mut ws_payload = ResponseCreateWsRequest {
|
||||
client_metadata: response_create_client_metadata(
|
||||
build_ws_client_metadata(turn_metadata_header),
|
||||
Some(self.client.build_ws_client_metadata(turn_metadata_header)),
|
||||
request_trace.as_ref(),
|
||||
),
|
||||
..ResponseCreateWsRequest::from(&request)
|
||||
@@ -1370,14 +1434,6 @@ fn parse_turn_metadata_header(turn_metadata_header: Option<&str>) -> Option<Head
|
||||
turn_metadata_header.and_then(|value| HeaderValue::from_str(value).ok())
|
||||
}
|
||||
|
||||
fn build_ws_client_metadata(turn_metadata_header: Option<&str>) -> Option<HashMap<String, String>> {
|
||||
let turn_metadata_header = parse_turn_metadata_header(turn_metadata_header)?;
|
||||
let turn_metadata = turn_metadata_header.to_str().ok()?.to_string();
|
||||
let mut client_metadata = HashMap::new();
|
||||
client_metadata.insert(X_CODEX_TURN_METADATA_HEADER.to_string(), turn_metadata);
|
||||
Some(client_metadata)
|
||||
}
|
||||
|
||||
/// Builds the extra headers attached to Responses API requests.
|
||||
///
|
||||
/// These headers implement Codex-specific conventions:
|
||||
@@ -1409,6 +1465,34 @@ fn build_responses_headers(
|
||||
headers
|
||||
}
|
||||
|
||||
fn subagent_header_value(session_source: &SessionSource) -> Option<String> {
|
||||
let SessionSource::SubAgent(subagent_source) = session_source else {
|
||||
return None;
|
||||
};
|
||||
match subagent_source {
|
||||
SubAgentSource::Review => Some("review".to_string()),
|
||||
SubAgentSource::Compact => Some("compact".to_string()),
|
||||
SubAgentSource::MemoryConsolidation => Some("memory_consolidation".to_string()),
|
||||
SubAgentSource::ThreadSpawn { .. } => Some("collab_spawn".to_string()),
|
||||
SubAgentSource::Other(label) => Some(label.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn parent_thread_id_header_value(session_source: &SessionSource) -> Option<String> {
|
||||
match session_source {
|
||||
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id, ..
|
||||
}) => Some(parent_thread_id.to_string()),
|
||||
SessionSource::Cli
|
||||
| SessionSource::VSCode
|
||||
| SessionSource::Exec
|
||||
| SessionSource::Mcp
|
||||
| SessionSource::Custom(_)
|
||||
| SessionSource::SubAgent(_)
|
||||
| SessionSource::Unknown => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_response_stream<S>(
|
||||
api_stream: S,
|
||||
session_telemetry: SessionTelemetry,
|
||||
|
||||
@@ -2,6 +2,10 @@ use super::AuthRequestTelemetryContext;
|
||||
use super::ModelClient;
|
||||
use super::PendingUnauthorizedRetry;
|
||||
use super::UnauthorizedRecoveryExecution;
|
||||
use super::X_CODEX_PARENT_THREAD_ID_HEADER;
|
||||
use super::X_CODEX_TURN_METADATA_HEADER;
|
||||
use super::X_CODEX_WINDOW_ID_HEADER;
|
||||
use super::X_OPENAI_SUBAGENT_HEADER;
|
||||
use codex_api::api_bridge::CoreAuthProvider;
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use codex_model_provider_info::WireApi;
|
||||
@@ -80,11 +84,49 @@ fn build_subagent_headers_sets_other_subagent_label() {
|
||||
)));
|
||||
let headers = client.build_subagent_headers();
|
||||
let value = headers
|
||||
.get("x-openai-subagent")
|
||||
.get(X_OPENAI_SUBAGENT_HEADER)
|
||||
.and_then(|value| value.to_str().ok());
|
||||
assert_eq!(value, Some("memory_consolidation"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ws_client_metadata_includes_window_lineage_and_turn_metadata() {
|
||||
let parent_thread_id = ThreadId::new();
|
||||
let client = test_model_client(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth: 2,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
}));
|
||||
|
||||
client.advance_window_generation();
|
||||
|
||||
let client_metadata = client.build_ws_client_metadata(Some(r#"{"turn_id":"turn-123"}"#));
|
||||
let conversation_id = client.state.conversation_id;
|
||||
assert_eq!(
|
||||
client_metadata,
|
||||
std::collections::HashMap::from([
|
||||
(
|
||||
X_CODEX_WINDOW_ID_HEADER.to_string(),
|
||||
format!("{conversation_id}:1"),
|
||||
),
|
||||
(
|
||||
X_OPENAI_SUBAGENT_HEADER.to_string(),
|
||||
"collab_spawn".to_string(),
|
||||
),
|
||||
(
|
||||
X_CODEX_PARENT_THREAD_ID_HEADER.to_string(),
|
||||
parent_thread_id.to_string(),
|
||||
),
|
||||
(
|
||||
X_CODEX_TURN_METADATA_HEADER.to_string(),
|
||||
r#"{"turn_id":"turn-123"}"#.to_string(),
|
||||
),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn summarize_memories_returns_empty_for_empty_input() {
|
||||
let client = test_model_client(SessionSource::Cli);
|
||||
|
||||
@@ -5,6 +5,8 @@ use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::time::SystemTime;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use crate::agent::AgentControl;
|
||||
use crate::agent::AgentStatus;
|
||||
@@ -48,6 +50,7 @@ use chrono::Utc;
|
||||
use codex_analytics::AnalyticsEventsClient;
|
||||
use codex_analytics::AppInvocation;
|
||||
use codex_analytics::InvocationType;
|
||||
use codex_analytics::SubAgentThreadStartedInput;
|
||||
use codex_analytics::build_track_events_context;
|
||||
use codex_app_server_protocol::McpServerElicitationRequest;
|
||||
use codex_app_server_protocol::McpServerElicitationRequestParams;
|
||||
@@ -332,6 +335,7 @@ use codex_protocol::config_types::ServiceTier;
|
||||
use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::DeveloperInstructions;
|
||||
use codex_protocol::models::MessagePhase;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
|
||||
@@ -638,6 +642,7 @@ impl Codex {
|
||||
original_config_do_not_use: Arc::clone(&config),
|
||||
metrics_service_name,
|
||||
app_server_client_name: None,
|
||||
app_server_client_version: None,
|
||||
session_source,
|
||||
dynamic_tools,
|
||||
persist_extended_history,
|
||||
@@ -757,13 +762,15 @@ impl Codex {
|
||||
self.session.steer_input(input, expected_turn_id).await
|
||||
}
|
||||
|
||||
pub(crate) async fn set_app_server_client_name(
|
||||
pub(crate) async fn set_app_server_client_info(
|
||||
&self,
|
||||
app_server_client_name: Option<String>,
|
||||
app_server_client_version: Option<String>,
|
||||
) -> ConstraintResult<()> {
|
||||
self.session
|
||||
.update_settings(SessionSettingsUpdate {
|
||||
app_server_client_name,
|
||||
app_server_client_version,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
@@ -1118,6 +1125,7 @@ pub(crate) struct SessionConfiguration {
|
||||
/// Optional service name tag for session metrics.
|
||||
metrics_service_name: Option<String>,
|
||||
app_server_client_name: Option<String>,
|
||||
app_server_client_version: Option<String>,
|
||||
/// Source of the session (cli, vscode, exec, mcp, ...)
|
||||
session_source: SessionSource,
|
||||
dynamic_tools: Vec<DynamicToolSpec>,
|
||||
@@ -1211,6 +1219,9 @@ impl SessionConfiguration {
|
||||
if let Some(app_server_client_name) = updates.app_server_client_name.clone() {
|
||||
next_configuration.app_server_client_name = Some(app_server_client_name);
|
||||
}
|
||||
if let Some(app_server_client_version) = updates.app_server_client_version.clone() {
|
||||
next_configuration.app_server_client_version = Some(app_server_client_version);
|
||||
}
|
||||
Ok(next_configuration)
|
||||
}
|
||||
}
|
||||
@@ -1228,9 +1239,26 @@ pub(crate) struct SessionSettingsUpdate {
|
||||
pub(crate) final_output_json_schema: Option<Option<Value>>,
|
||||
pub(crate) personality: Option<Personality>,
|
||||
pub(crate) app_server_client_name: Option<String>,
|
||||
pub(crate) app_server_client_version: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) struct AppServerClientMetadata {
|
||||
pub(crate) client_name: Option<String>,
|
||||
pub(crate) client_version: Option<String>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub(crate) async fn app_server_client_metadata(&self) -> AppServerClientMetadata {
|
||||
let state = self.state.lock().await;
|
||||
AppServerClientMetadata {
|
||||
client_name: state.session_configuration.app_server_client_name.clone(),
|
||||
client_version: state
|
||||
.session_configuration
|
||||
.app_server_client_version
|
||||
.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the `x-codex-beta-features` header value for this session.
|
||||
///
|
||||
/// `ModelClient` is session-scoped and intentionally does not depend on the full `Config`, so
|
||||
@@ -1530,6 +1558,17 @@ impl Session {
|
||||
),
|
||||
),
|
||||
};
|
||||
let window_generation = match &initial_history {
|
||||
InitialHistory::Resumed(resumed_history) => u64::try_from(
|
||||
resumed_history
|
||||
.history
|
||||
.iter()
|
||||
.filter(|item| matches!(item, RolloutItem::Compacted(_)))
|
||||
.count(),
|
||||
)
|
||||
.unwrap_or(u64::MAX),
|
||||
InitialHistory::New | InitialHistory::Forked(_) => 0,
|
||||
};
|
||||
let state_builder = match &initial_history {
|
||||
InitialHistory::Resumed(resumed) => metadata::builder_from_items(
|
||||
resumed.history.as_slice(),
|
||||
@@ -1919,6 +1958,9 @@ impl Session {
|
||||
),
|
||||
environment: environment_manager.current().await?,
|
||||
};
|
||||
services
|
||||
.model_client
|
||||
.set_window_generation(window_generation);
|
||||
let js_repl = Arc::new(JsReplHandle::with_node_path(
|
||||
config.js_repl_node_path.clone(),
|
||||
config.js_repl_node_module_dirs.clone(),
|
||||
@@ -3513,6 +3555,7 @@ impl Session {
|
||||
self.persist_rollout_items(&[RolloutItem::TurnContext(turn_context_item)])
|
||||
.await;
|
||||
}
|
||||
self.services.model_client.advance_window_generation();
|
||||
}
|
||||
|
||||
async fn persist_rollout_response_items(&self, items: &[ResponseItem]) {
|
||||
@@ -3578,22 +3621,24 @@ impl Session {
|
||||
{
|
||||
developer_sections.push(model_switch_message.into_text());
|
||||
}
|
||||
developer_sections.push(
|
||||
DeveloperInstructions::from_policy(
|
||||
turn_context.sandbox_policy.get(),
|
||||
turn_context.approval_policy.value(),
|
||||
turn_context.config.approvals_reviewer,
|
||||
self.services.exec_policy.current().as_ref(),
|
||||
&turn_context.cwd,
|
||||
turn_context
|
||||
.features
|
||||
.enabled(Feature::ExecPermissionApprovals),
|
||||
turn_context
|
||||
.features
|
||||
.enabled(Feature::RequestPermissionsTool),
|
||||
)
|
||||
.into_text(),
|
||||
);
|
||||
if turn_context.config.include_permissions_instructions {
|
||||
developer_sections.push(
|
||||
DeveloperInstructions::from_policy(
|
||||
turn_context.sandbox_policy.get(),
|
||||
turn_context.approval_policy.value(),
|
||||
turn_context.config.approvals_reviewer,
|
||||
self.services.exec_policy.current().as_ref(),
|
||||
&turn_context.cwd,
|
||||
turn_context
|
||||
.features
|
||||
.enabled(Feature::ExecPermissionApprovals),
|
||||
turn_context
|
||||
.features
|
||||
.enabled(Feature::RequestPermissionsTool),
|
||||
)
|
||||
.into_text(),
|
||||
);
|
||||
}
|
||||
let separate_guardian_developer_message =
|
||||
crate::guardian::is_guardian_reviewer_source(&session_source);
|
||||
// Keep the guardian policy prompt out of the aggregated developer bundle so it
|
||||
@@ -3643,7 +3688,7 @@ impl Session {
|
||||
);
|
||||
}
|
||||
}
|
||||
if turn_context.apps_enabled() {
|
||||
if turn_context.config.include_apps_instructions && turn_context.apps_enabled() {
|
||||
let mcp_connection_manager = self.services.mcp_connection_manager.read().await;
|
||||
let accessible_and_enabled_connectors =
|
||||
connectors::list_accessible_and_enabled_connectors_from_manager(
|
||||
@@ -3686,16 +3731,18 @@ impl Session {
|
||||
.serialize_to_text(),
|
||||
);
|
||||
}
|
||||
let subagents = self
|
||||
.services
|
||||
.agent_control
|
||||
.format_environment_context_subagents(self.conversation_id)
|
||||
.await;
|
||||
contextual_user_sections.push(
|
||||
EnvironmentContext::from_turn_context(turn_context, shell.as_ref())
|
||||
.with_subagents(subagents)
|
||||
.serialize_to_xml(),
|
||||
);
|
||||
if turn_context.config.include_environment_context {
|
||||
let subagents = self
|
||||
.services
|
||||
.agent_control
|
||||
.format_environment_context_subagents(self.conversation_id)
|
||||
.await;
|
||||
contextual_user_sections.push(
|
||||
EnvironmentContext::from_turn_context(turn_context, shell.as_ref())
|
||||
.with_subagents(subagents)
|
||||
.serialize_to_xml(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut items = Vec::with_capacity(3);
|
||||
if let Some(developer_message) =
|
||||
@@ -4418,6 +4465,37 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn emit_subagent_session_started(
|
||||
analytics_events_client: &AnalyticsEventsClient,
|
||||
client_metadata: AppServerClientMetadata,
|
||||
thread_id: ThreadId,
|
||||
thread_config: ThreadConfigSnapshot,
|
||||
subagent_source: SubAgentSource,
|
||||
) {
|
||||
let AppServerClientMetadata {
|
||||
client_name,
|
||||
client_version,
|
||||
} = client_metadata;
|
||||
let (Some(client_name), Some(client_version)) = (client_name, client_version) else {
|
||||
tracing::warn!("skipping subagent thread analytics: missing inherited client metadata");
|
||||
return;
|
||||
};
|
||||
let created_at = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
analytics_events_client.track_subagent_thread_started(SubAgentThreadStartedInput {
|
||||
thread_id: thread_id.to_string(),
|
||||
product_client_id: client_name.clone(),
|
||||
client_name,
|
||||
client_version,
|
||||
model: thread_config.model,
|
||||
ephemeral: thread_config.ephemeral,
|
||||
subagent_source,
|
||||
created_at,
|
||||
});
|
||||
}
|
||||
|
||||
async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiver<Submission>) {
|
||||
// To break out of this loop, send Op::Shutdown.
|
||||
while let Ok(sub) = rx_sub.recv().await {
|
||||
@@ -4785,6 +4863,7 @@ mod handlers {
|
||||
final_output_json_schema: Some(final_output_json_schema),
|
||||
personality,
|
||||
app_server_client_name: None,
|
||||
app_server_client_version: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -5728,16 +5807,20 @@ pub(crate) async fn run_turn(
|
||||
|
||||
let model_info = turn_context.model_info.clone();
|
||||
let auto_compact_limit = model_info.auto_compact_token_limit().unwrap_or(i64::MAX);
|
||||
let mut prewarmed_client_session = prewarmed_client_session;
|
||||
// TODO(ccunningham): Pre-turn compaction runs before context updates and the
|
||||
// new user message are recorded. Estimate pending incoming items (context
|
||||
// diffs/full reinjection + user input) and trigger compaction preemptively
|
||||
// when they would push the thread over the compaction threshold.
|
||||
if run_pre_sampling_compact(&sess, &turn_context)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
error!("Failed to run pre-sampling compact");
|
||||
return None;
|
||||
let pre_sampling_compacted = match run_pre_sampling_compact(&sess, &turn_context).await {
|
||||
Ok(pre_sampling_compacted) => pre_sampling_compacted,
|
||||
Err(_) => {
|
||||
error!("Failed to run pre-sampling compact");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if pre_sampling_compacted && let Some(mut client_session) = prewarmed_client_session.take() {
|
||||
client_session.reset_websocket_session();
|
||||
}
|
||||
|
||||
let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref());
|
||||
@@ -6050,6 +6133,7 @@ pub(crate) async fn run_turn(
|
||||
{
|
||||
return None;
|
||||
}
|
||||
client_session.reset_websocket_session();
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -6210,9 +6294,9 @@ pub(crate) async fn run_turn(
|
||||
async fn run_pre_sampling_compact(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
) -> CodexResult<()> {
|
||||
) -> CodexResult<bool> {
|
||||
let total_usage_tokens_before_compaction = sess.get_total_token_usage().await;
|
||||
maybe_run_previous_model_inline_compact(
|
||||
let mut pre_sampling_compacted = maybe_run_previous_model_inline_compact(
|
||||
sess,
|
||||
turn_context,
|
||||
total_usage_tokens_before_compaction,
|
||||
@@ -6226,8 +6310,9 @@ async fn run_pre_sampling_compact(
|
||||
// Compact if the total usage tokens are greater than the auto compact limit
|
||||
if total_usage_tokens >= auto_compact_limit {
|
||||
run_auto_compact(sess, turn_context, InitialContextInjection::DoNotInject).await?;
|
||||
pre_sampling_compacted = true;
|
||||
}
|
||||
Ok(())
|
||||
Ok(pre_sampling_compacted)
|
||||
}
|
||||
|
||||
/// Runs pre-sampling compaction against the previous model when switching to a smaller
|
||||
@@ -6485,6 +6570,7 @@ pub(crate) fn build_prompt(
|
||||
output_schema: turn_context.final_output_json_schema.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[instrument(level = "trace",
|
||||
skip_all,
|
||||
@@ -7421,6 +7507,25 @@ async fn try_run_sampling_request(
|
||||
cancellation_token: cancellation_token.child_token(),
|
||||
};
|
||||
|
||||
let preempt_for_mailbox_mail = match &item {
|
||||
ResponseItem::Message { role, phase, .. } => {
|
||||
role == "assistant" && matches!(phase, Some(MessagePhase::Commentary))
|
||||
}
|
||||
ResponseItem::Reasoning { .. } => true,
|
||||
ResponseItem::LocalShellCall { .. }
|
||||
| ResponseItem::FunctionCall { .. }
|
||||
| ResponseItem::ToolSearchCall { .. }
|
||||
| ResponseItem::FunctionCallOutput { .. }
|
||||
| ResponseItem::CustomToolCall { .. }
|
||||
| ResponseItem::CustomToolCallOutput { .. }
|
||||
| ResponseItem::ToolSearchOutput { .. }
|
||||
| ResponseItem::WebSearchCall { .. }
|
||||
| ResponseItem::ImageGenerationCall { .. }
|
||||
| ResponseItem::GhostSnapshot { .. }
|
||||
| ResponseItem::Compaction { .. }
|
||||
| ResponseItem::Other => false,
|
||||
};
|
||||
|
||||
let output_result = handle_output_item_done(&mut ctx, item, previously_active_item)
|
||||
.instrument(handle_responses)
|
||||
.await?;
|
||||
@@ -7431,6 +7536,13 @@ async fn try_run_sampling_request(
|
||||
last_agent_message = Some(agent_message);
|
||||
}
|
||||
needs_follow_up |= output_result.needs_follow_up;
|
||||
// todo: remove before stabilizing multi-agent v2
|
||||
if preempt_for_mailbox_mail && sess.mailbox_rx.lock().await.has_pending() {
|
||||
break Ok(SamplingRequestResult {
|
||||
needs_follow_up: true,
|
||||
last_agent_message,
|
||||
});
|
||||
}
|
||||
}
|
||||
ResponseEvent::OutputItemAdded(item) => {
|
||||
if let Some(turn_item) = handle_non_tool_response_item(
|
||||
|
||||
@@ -36,6 +36,7 @@ use crate::codex::CodexSpawnOk;
|
||||
use crate::codex::SUBMISSION_CHANNEL_CAPACITY;
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::codex::emit_subagent_session_started;
|
||||
use crate::config::Config;
|
||||
use crate::guardian::GuardianApprovalRequest;
|
||||
use crate::guardian::review_approval_request_with_cancel;
|
||||
@@ -85,7 +86,7 @@ pub(crate) async fn run_codex_thread_interactive(
|
||||
mcp_manager: Arc::clone(&parent_session.services.mcp_manager),
|
||||
skills_watcher: Arc::clone(&parent_session.services.skills_watcher),
|
||||
conversation_history: initial_history.unwrap_or(InitialHistory::New),
|
||||
session_source: SessionSource::SubAgent(subagent_source),
|
||||
session_source: SessionSource::SubAgent(subagent_source.clone()),
|
||||
agent_control: parent_session.services.agent_control.clone(),
|
||||
dynamic_tools: Vec::new(),
|
||||
persist_extended_history: false,
|
||||
@@ -96,6 +97,17 @@ pub(crate) async fn run_codex_thread_interactive(
|
||||
parent_trace: None,
|
||||
})
|
||||
.await?;
|
||||
if parent_session.enabled(codex_features::Feature::GeneralAnalytics) {
|
||||
let thread_config = codex.thread_config_snapshot().await;
|
||||
let client_metadata = parent_session.app_server_client_metadata().await;
|
||||
emit_subagent_session_started(
|
||||
&parent_session.services.analytics_events_client,
|
||||
client_metadata,
|
||||
codex.session.conversation_id,
|
||||
thread_config,
|
||||
subagent_source,
|
||||
);
|
||||
}
|
||||
let codex = Arc::new(codex);
|
||||
|
||||
// Use a child token so parent cancel cascades but we can scope it to this task
|
||||
|
||||
@@ -43,7 +43,6 @@ use crate::state::TaskKind;
|
||||
use crate::tasks::SessionTask;
|
||||
use crate::tasks::SessionTaskContext;
|
||||
use crate::tools::ToolRouter;
|
||||
use crate::tools::context::FunctionToolOutput;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::handlers::ShellHandler;
|
||||
@@ -120,12 +119,6 @@ use std::time::Duration as StdDuration;
|
||||
#[path = "codex_tests_guardian.rs"]
|
||||
mod guardian_tests;
|
||||
|
||||
use codex_protocol::models::function_call_output_content_items_to_text;
|
||||
|
||||
fn expect_text_tool_output(output: &FunctionToolOutput) -> String {
|
||||
function_call_output_content_items_to_text(&output.body).unwrap_or_default()
|
||||
}
|
||||
|
||||
struct InstructionsTestCase {
|
||||
slug: &'static str,
|
||||
expects_apply_patch_instructions: bool,
|
||||
@@ -283,6 +276,23 @@ fn developer_input_texts(items: &[ResponseItem]) -> Vec<&str> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn user_input_texts(items: &[ResponseItem]) -> Vec<&str> {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
ResponseItem::Message { role, content, .. } if role == "user" => {
|
||||
Some(content.as_slice())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.flat_map(|content| content.iter())
|
||||
.filter_map(|item| match item {
|
||||
ContentItem::InputText { text } => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn test_tool_runtime(session: Arc<Session>, turn_context: Arc<TurnContext>) -> ToolCallRuntime {
|
||||
let router = Arc::new(ToolRouter::from_config(
|
||||
&turn_context.tools_config,
|
||||
@@ -1826,6 +1836,7 @@ async fn set_rate_limits_retains_previous_credits() {
|
||||
original_config_do_not_use: Arc::clone(&config),
|
||||
metrics_service_name: None,
|
||||
app_server_client_name: None,
|
||||
app_server_client_version: None,
|
||||
session_source: SessionSource::Exec,
|
||||
dynamic_tools: Vec::new(),
|
||||
persist_extended_history: false,
|
||||
@@ -1927,6 +1938,7 @@ async fn set_rate_limits_updates_plan_type_when_present() {
|
||||
original_config_do_not_use: Arc::clone(&config),
|
||||
metrics_service_name: None,
|
||||
app_server_client_name: None,
|
||||
app_server_client_version: None,
|
||||
session_source: SessionSource::Exec,
|
||||
dynamic_tools: Vec::new(),
|
||||
persist_extended_history: false,
|
||||
@@ -2275,6 +2287,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati
|
||||
original_config_do_not_use: Arc::clone(&config),
|
||||
metrics_service_name: None,
|
||||
app_server_client_name: None,
|
||||
app_server_client_version: None,
|
||||
session_source: SessionSource::Exec,
|
||||
dynamic_tools: Vec::new(),
|
||||
persist_extended_history: false,
|
||||
@@ -2540,6 +2553,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() {
|
||||
original_config_do_not_use: Arc::clone(&config),
|
||||
metrics_service_name: None,
|
||||
app_server_client_name: None,
|
||||
app_server_client_version: None,
|
||||
session_source: SessionSource::Exec,
|
||||
dynamic_tools: Vec::new(),
|
||||
persist_extended_history: false,
|
||||
@@ -2640,6 +2654,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
original_config_do_not_use: Arc::clone(&config),
|
||||
metrics_service_name: None,
|
||||
app_server_client_name: None,
|
||||
app_server_client_version: None,
|
||||
session_source: SessionSource::Exec,
|
||||
dynamic_tools: Vec::new(),
|
||||
persist_extended_history: false,
|
||||
@@ -3479,6 +3494,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
|
||||
original_config_do_not_use: Arc::clone(&config),
|
||||
metrics_service_name: None,
|
||||
app_server_client_name: None,
|
||||
app_server_client_version: None,
|
||||
session_source: SessionSource::Exec,
|
||||
dynamic_tools,
|
||||
persist_extended_history: false,
|
||||
@@ -3774,17 +3790,9 @@ async fn build_settings_update_items_emits_environment_item_for_network_changes(
|
||||
.build_settings_update_items(Some(&reference_context_item), ¤t_context)
|
||||
.await;
|
||||
|
||||
let environment_update = update_items
|
||||
.iter()
|
||||
.find_map(|item| match item {
|
||||
ResponseItem::Message { role, content, .. } if role == "user" => {
|
||||
let [ContentItem::InputText { text }] = content.as_slice() else {
|
||||
return None;
|
||||
};
|
||||
text.contains("<environment_context>").then_some(text)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
let environment_update = user_input_texts(&update_items)
|
||||
.into_iter()
|
||||
.find(|text| text.contains("<environment_context>"))
|
||||
.expect("environment update item should be emitted");
|
||||
assert!(environment_update.contains("<network enabled=\"true\">"));
|
||||
assert!(environment_update.contains("<allowed>api.example.com</allowed>"));
|
||||
@@ -3809,22 +3817,43 @@ async fn build_settings_update_items_emits_environment_item_for_time_changes() {
|
||||
.build_settings_update_items(Some(&reference_context_item), ¤t_context)
|
||||
.await;
|
||||
|
||||
let environment_update = update_items
|
||||
.iter()
|
||||
.find_map(|item| match item {
|
||||
ResponseItem::Message { role, content, .. } if role == "user" => {
|
||||
let [ContentItem::InputText { text }] = content.as_slice() else {
|
||||
return None;
|
||||
};
|
||||
text.contains("<environment_context>").then_some(text)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
let environment_update = user_input_texts(&update_items)
|
||||
.into_iter()
|
||||
.find(|text| text.contains("<environment_context>"))
|
||||
.expect("environment update item should be emitted");
|
||||
assert!(environment_update.contains("<current_date>2026-02-27</current_date>"));
|
||||
assert!(environment_update.contains("<timezone>Europe/Berlin</timezone>"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_settings_update_items_omits_environment_item_when_disabled() {
|
||||
let (session, previous_context) = make_session_and_context().await;
|
||||
let previous_context = Arc::new(previous_context);
|
||||
let mut current_context = previous_context
|
||||
.with_model(
|
||||
previous_context.model_info.slug.clone(),
|
||||
&session.services.models_manager,
|
||||
)
|
||||
.await;
|
||||
let mut config = (*current_context.config).clone();
|
||||
config.include_environment_context = false;
|
||||
current_context.config = Arc::new(config);
|
||||
current_context.current_date = Some("2026-02-27".to_string());
|
||||
|
||||
let reference_context_item = previous_context.to_turn_context_item();
|
||||
let update_items = session
|
||||
.build_settings_update_items(Some(&reference_context_item), ¤t_context)
|
||||
.await;
|
||||
|
||||
let user_texts = user_input_texts(&update_items);
|
||||
assert!(
|
||||
!user_texts
|
||||
.iter()
|
||||
.any(|text| text.contains("<environment_context>")),
|
||||
"did not expect environment context updates when disabled, got {user_texts:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_settings_update_items_emits_realtime_start_when_session_becomes_live() {
|
||||
let (session, previous_context) = make_session_and_context().await;
|
||||
@@ -5312,7 +5341,9 @@ async fn sample_rollout(
|
||||
#[tokio::test]
|
||||
async fn rejects_escalated_permissions_when_policy_not_on_request() {
|
||||
use crate::exec::ExecParams;
|
||||
use crate::exec_policy::ExecApprovalRequest;
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
use crate::tools::sandboxing::ExecApprovalRequirement;
|
||||
use crate::turn_diff_tracker::TurnDiffTracker;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
@@ -5358,23 +5389,6 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() {
|
||||
arg0: None,
|
||||
};
|
||||
|
||||
let params2 = ExecParams {
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
command: params.command.clone(),
|
||||
cwd: params.cwd.clone(),
|
||||
expiration: timeout_ms.into(),
|
||||
capture_policy: ExecCapturePolicy::ShellTool,
|
||||
env: HashMap::new(),
|
||||
network: None,
|
||||
windows_sandbox_level: turn_context.windows_sandbox_level,
|
||||
windows_sandbox_private_desktop: turn_context
|
||||
.config
|
||||
.permissions
|
||||
.windows_sandbox_private_desktop,
|
||||
justification: params.justification.clone(),
|
||||
arg0: None,
|
||||
};
|
||||
|
||||
let turn_diff_tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
|
||||
|
||||
let tool_name = "shell";
|
||||
@@ -5412,9 +5426,11 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() {
|
||||
);
|
||||
|
||||
pretty_assertions::assert_eq!(output, expected);
|
||||
pretty_assertions::assert_eq!(session.granted_turn_permissions().await, None);
|
||||
|
||||
// Now retry the same command WITHOUT escalated permissions; should succeed.
|
||||
// Force DangerFullAccess to avoid platform sandbox dependencies in tests.
|
||||
// The rejection should not poison the non-escalated path for the same
|
||||
// command. Force DangerFullAccess so this check stays focused on approval
|
||||
// policy rather than platform-specific sandbox behavior.
|
||||
let turn_context_mut = Arc::get_mut(&mut turn_context).expect("unique turn context Arc");
|
||||
turn_context_mut
|
||||
.sandbox_policy
|
||||
@@ -5425,45 +5441,22 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() {
|
||||
turn_context_mut.network_sandbox_policy =
|
||||
NetworkSandboxPolicy::from(turn_context_mut.sandbox_policy.get());
|
||||
|
||||
let resp2 = handler
|
||||
.handle(ToolInvocation {
|
||||
session: Arc::clone(&session),
|
||||
turn: Arc::clone(&turn_context),
|
||||
tracker: Arc::clone(&turn_diff_tracker),
|
||||
call_id: "test-call-2".to_string(),
|
||||
tool_name: tool_name.to_string(),
|
||||
tool_namespace: None,
|
||||
payload: ToolPayload::Function {
|
||||
arguments: serde_json::json!({
|
||||
"command": params2.command.clone(),
|
||||
"workdir": Some(turn_context.cwd.to_string_lossy().to_string()),
|
||||
"timeout_ms": params2.expiration.timeout_ms(),
|
||||
"sandbox_permissions": params2.sandbox_permissions,
|
||||
"justification": params2.justification.clone(),
|
||||
})
|
||||
.to_string(),
|
||||
},
|
||||
let exec_approval_requirement = session
|
||||
.services
|
||||
.exec_policy
|
||||
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
|
||||
command: ¶ms.command,
|
||||
approval_policy: turn_context.approval_policy.value(),
|
||||
sandbox_policy: turn_context.sandbox_policy.get(),
|
||||
file_system_sandbox_policy: &turn_context.file_system_sandbox_policy,
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
})
|
||||
.await;
|
||||
|
||||
let output = expect_text_tool_output(&resp2.expect("expected Ok result"));
|
||||
|
||||
#[derive(Deserialize, PartialEq, Eq, Debug)]
|
||||
struct ResponseExecMetadata {
|
||||
exit_code: i32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ResponseExecOutput {
|
||||
output: String,
|
||||
metadata: ResponseExecMetadata,
|
||||
}
|
||||
|
||||
let exec_output: ResponseExecOutput =
|
||||
serde_json::from_str(&output).expect("valid exec output json");
|
||||
|
||||
pretty_assertions::assert_eq!(exec_output.metadata, ResponseExecMetadata { exit_code: 0 });
|
||||
assert!(exec_output.output.contains("hi"));
|
||||
assert!(matches!(
|
||||
exec_approval_requirement,
|
||||
ExecApprovalRequirement::Skip { .. }
|
||||
));
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn unified_exec_rejects_escalated_permissions_when_policy_not_on_request() {
|
||||
|
||||
@@ -100,12 +100,13 @@ impl CodexThread {
|
||||
self.codex.steer_input(input, expected_turn_id).await
|
||||
}
|
||||
|
||||
pub async fn set_app_server_client_name(
|
||||
pub async fn set_app_server_client_info(
|
||||
&self,
|
||||
app_server_client_name: Option<String>,
|
||||
app_server_client_version: Option<String>,
|
||||
) -> ConstraintResult<()> {
|
||||
self.codex
|
||||
.set_app_server_client_name(app_server_client_name)
|
||||
.set_app_server_client_info(app_server_client_name, app_server_client_version)
|
||||
.await
|
||||
}
|
||||
|
||||
|
||||
@@ -220,6 +220,7 @@ async fn run_compact_task_inner(
|
||||
};
|
||||
sess.replace_compacted_history(new_history, reference_context_item, compacted_item)
|
||||
.await;
|
||||
client_session.reset_websocket_session();
|
||||
sess.recompute_token_usage(&turn_context).await;
|
||||
|
||||
sess.emit_turn_item_completed(&turn_context, compaction_item)
|
||||
|
||||
@@ -4493,6 +4493,9 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
|
||||
base_instructions: None,
|
||||
developer_instructions: None,
|
||||
guardian_developer_instructions: None,
|
||||
include_permissions_instructions: true,
|
||||
include_apps_instructions: true,
|
||||
include_environment_context: true,
|
||||
compact_prompt: None,
|
||||
commit_attribution: None,
|
||||
forced_chatgpt_workspace_id: None,
|
||||
@@ -4635,6 +4638,9 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
|
||||
base_instructions: None,
|
||||
developer_instructions: None,
|
||||
guardian_developer_instructions: None,
|
||||
include_permissions_instructions: true,
|
||||
include_apps_instructions: true,
|
||||
include_environment_context: true,
|
||||
compact_prompt: None,
|
||||
commit_attribution: None,
|
||||
forced_chatgpt_workspace_id: None,
|
||||
@@ -4775,6 +4781,9 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
|
||||
base_instructions: None,
|
||||
developer_instructions: None,
|
||||
guardian_developer_instructions: None,
|
||||
include_permissions_instructions: true,
|
||||
include_apps_instructions: true,
|
||||
include_environment_context: true,
|
||||
compact_prompt: None,
|
||||
commit_attribution: None,
|
||||
forced_chatgpt_workspace_id: None,
|
||||
@@ -4901,6 +4910,9 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
|
||||
base_instructions: None,
|
||||
developer_instructions: None,
|
||||
guardian_developer_instructions: None,
|
||||
include_permissions_instructions: true,
|
||||
include_apps_instructions: true,
|
||||
include_environment_context: true,
|
||||
compact_prompt: None,
|
||||
commit_attribution: None,
|
||||
forced_chatgpt_workspace_id: None,
|
||||
@@ -5783,6 +5795,35 @@ async fn approvals_reviewer_defaults_to_manual_only_without_guardian_feature() -
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prompt_instruction_blocks_can_be_disabled_from_config_and_profiles() -> std::io::Result<()>
|
||||
{
|
||||
let codex_home = TempDir::new()?;
|
||||
std::fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
r#"include_permissions_instructions = false
|
||||
include_apps_instructions = false
|
||||
include_environment_context = false
|
||||
profile = "chatty"
|
||||
|
||||
[profiles.chatty]
|
||||
include_permissions_instructions = true
|
||||
include_environment_context = true
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
assert!(config.include_permissions_instructions);
|
||||
assert!(!config.include_apps_instructions);
|
||||
assert!(config.include_environment_context);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn approvals_reviewer_stays_manual_only_when_guardian_feature_is_enabled()
|
||||
-> std::io::Result<()> {
|
||||
|
||||
@@ -280,6 +280,15 @@ pub struct Config {
|
||||
/// Guardian-specific developer instructions override from requirements.toml.
|
||||
pub guardian_developer_instructions: Option<String>,
|
||||
|
||||
/// Whether to inject the `<permissions instructions>` developer block.
|
||||
pub include_permissions_instructions: bool,
|
||||
|
||||
/// Whether to inject the `<apps_instructions>` developer block.
|
||||
pub include_apps_instructions: bool,
|
||||
|
||||
/// Whether to inject the `<environment_context>` user block.
|
||||
pub include_environment_context: bool,
|
||||
|
||||
/// Compact prompt override.
|
||||
pub compact_prompt: Option<String>,
|
||||
|
||||
@@ -1183,6 +1192,15 @@ pub struct ConfigToml {
|
||||
#[serde(default)]
|
||||
pub developer_instructions: Option<String>,
|
||||
|
||||
/// Whether to inject the `<permissions instructions>` developer block.
|
||||
pub include_permissions_instructions: Option<bool>,
|
||||
|
||||
/// Whether to inject the `<apps_instructions>` developer block.
|
||||
pub include_apps_instructions: Option<bool>,
|
||||
|
||||
/// Whether to inject the `<environment_context>` user block.
|
||||
pub include_environment_context: Option<bool>,
|
||||
|
||||
/// Optional path to a file containing model instructions that will override
|
||||
/// the built-in instructions for the selected model. Users are STRONGLY
|
||||
/// DISCOURAGED from using this field, as deviating from the instructions
|
||||
@@ -2452,6 +2470,18 @@ impl Config {
|
||||
Self::try_read_non_empty_file(model_instructions_path, "model instructions file")?;
|
||||
let base_instructions = base_instructions.or(file_base_instructions);
|
||||
let developer_instructions = developer_instructions.or(cfg.developer_instructions);
|
||||
let include_permissions_instructions = config_profile
|
||||
.include_permissions_instructions
|
||||
.or(cfg.include_permissions_instructions)
|
||||
.unwrap_or(true);
|
||||
let include_apps_instructions = config_profile
|
||||
.include_apps_instructions
|
||||
.or(cfg.include_apps_instructions)
|
||||
.unwrap_or(true);
|
||||
let include_environment_context = config_profile
|
||||
.include_environment_context
|
||||
.or(cfg.include_environment_context)
|
||||
.unwrap_or(true);
|
||||
let guardian_developer_instructions = guardian_developer_instructions_from_requirements(
|
||||
config_layer_stack.requirements_toml(),
|
||||
);
|
||||
@@ -2618,6 +2648,9 @@ impl Config {
|
||||
developer_instructions,
|
||||
compact_prompt,
|
||||
commit_attribution,
|
||||
include_permissions_instructions,
|
||||
include_apps_instructions,
|
||||
include_environment_context,
|
||||
// The config.toml omits "_mode" because it's a config file. However, "_mode"
|
||||
// is important in code to differentiate the mode from the store implementation.
|
||||
cli_auth_credentials_store_mode: cfg.cli_auth_credentials_store.unwrap_or_default(),
|
||||
|
||||
@@ -49,6 +49,9 @@ pub struct ConfigProfile {
|
||||
pub experimental_instructions_file: Option<AbsolutePathBuf>,
|
||||
pub experimental_compact_prompt_file: Option<AbsolutePathBuf>,
|
||||
pub include_apply_patch_tool: Option<bool>,
|
||||
pub include_permissions_instructions: Option<bool>,
|
||||
pub include_apps_instructions: Option<bool>,
|
||||
pub include_environment_context: Option<bool>,
|
||||
pub experimental_use_unified_exec_tool: Option<bool>,
|
||||
pub experimental_use_freeform_apply_patch: Option<bool>,
|
||||
pub tools_view_image: Option<bool>,
|
||||
|
||||
@@ -16,6 +16,10 @@ fn build_environment_update_item(
|
||||
next: &TurnContext,
|
||||
shell: &Shell,
|
||||
) -> Option<ResponseItem> {
|
||||
if !next.config.include_environment_context {
|
||||
return None;
|
||||
}
|
||||
|
||||
let prev = previous?;
|
||||
let prev_context = EnvironmentContext::from_turn_context_item(prev, shell);
|
||||
let next_context = EnvironmentContext::from_turn_context(next, shell);
|
||||
@@ -33,6 +37,10 @@ fn build_permissions_update_item(
|
||||
next: &TurnContext,
|
||||
exec_policy: &Policy,
|
||||
) -> Option<DeveloperInstructions> {
|
||||
if !next.config.include_permissions_instructions {
|
||||
return None;
|
||||
}
|
||||
|
||||
let prev = previous?;
|
||||
if prev.sandbox_policy == *next.sandbox_policy.get()
|
||||
&& prev.approval_policy == next.approval_policy.value()
|
||||
|
||||
@@ -58,6 +58,8 @@ pub mod utils;
|
||||
pub use utils::path_utils;
|
||||
pub mod personality_migration;
|
||||
pub mod plugins;
|
||||
#[doc(hidden)]
|
||||
pub mod prompt_debug;
|
||||
pub(crate) mod mentions {
|
||||
pub(crate) use crate::plugins::build_connector_slug_counts;
|
||||
pub(crate) use crate::plugins::build_skill_name_counts;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::agent::AgentStatus;
|
||||
use crate::agent::status::is_final as is_final_agent_status;
|
||||
use crate::codex::Session;
|
||||
use crate::codex::emit_subagent_session_started;
|
||||
use crate::config::Config;
|
||||
use crate::memories::memory_root;
|
||||
use crate::memories::metrics;
|
||||
@@ -143,6 +144,26 @@ pub(super) async fn run(session: &Arc<Session>, config: Arc<Config>) {
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(thread_config) = session
|
||||
.services
|
||||
.agent_control
|
||||
.get_agent_config_snapshot(thread_id)
|
||||
.await
|
||||
{
|
||||
if session.enabled(Feature::GeneralAnalytics) {
|
||||
let client_metadata = session.app_server_client_metadata().await;
|
||||
emit_subagent_session_started(
|
||||
&session.services.analytics_events_client,
|
||||
client_metadata,
|
||||
thread_id,
|
||||
thread_config,
|
||||
SubAgentSource::MemoryConsolidation,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
warn!("failed to load memory consolidation thread config for analytics: {thread_id}");
|
||||
}
|
||||
|
||||
// 6. Spawn the agent handler.
|
||||
agent::handle(
|
||||
session,
|
||||
|
||||
152
codex-rs/core/src/prompt_debug.rs
Normal file
152
codex-rs/core/src/prompt_debug.rs
Normal file
@@ -0,0 +1,152 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_exec_server::EnvironmentManager;
|
||||
use codex_features::Feature;
|
||||
use codex_login::AuthManager;
|
||||
use codex_models_manager::collaboration_mode_presets::CollaborationModesConfig;
|
||||
use codex_protocol::error::Result as CodexResult;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::codex::Session;
|
||||
use crate::codex::build_prompt;
|
||||
use crate::codex::built_tools;
|
||||
use crate::config::Config;
|
||||
use crate::thread_manager::ThreadManager;
|
||||
|
||||
/// Build the model-visible `input` list for a single debug turn.
|
||||
#[doc(hidden)]
|
||||
pub async fn build_prompt_input(
|
||||
mut config: Config,
|
||||
input: Vec<UserInput>,
|
||||
) -> CodexResult<Vec<ResponseItem>> {
|
||||
config.ephemeral = true;
|
||||
|
||||
let auth_manager = AuthManager::shared(
|
||||
config.codex_home.clone(),
|
||||
/*enable_codex_api_key_env*/ false,
|
||||
config.cli_auth_credentials_store_mode,
|
||||
);
|
||||
auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id.clone());
|
||||
|
||||
let thread_manager = ThreadManager::new(
|
||||
&config,
|
||||
Arc::clone(&auth_manager),
|
||||
SessionSource::Exec,
|
||||
CollaborationModesConfig {
|
||||
default_mode_request_user_input: config
|
||||
.features
|
||||
.enabled(Feature::DefaultModeRequestUserInput),
|
||||
},
|
||||
Arc::new(EnvironmentManager::from_env()),
|
||||
);
|
||||
let thread = thread_manager.start_thread(config).await?;
|
||||
|
||||
let output = build_prompt_input_from_session(thread.thread.codex.session.as_ref(), input).await;
|
||||
let shutdown = thread.thread.shutdown_and_wait().await;
|
||||
let _removed = thread_manager.remove_thread(&thread.thread_id).await;
|
||||
|
||||
shutdown?;
|
||||
output
|
||||
}
|
||||
|
||||
pub(crate) async fn build_prompt_input_from_session(
|
||||
sess: &Session,
|
||||
input: Vec<UserInput>,
|
||||
) -> CodexResult<Vec<ResponseItem>> {
|
||||
let turn_context = sess.new_default_turn().await;
|
||||
sess.record_context_updates_and_set_reference_context_item(turn_context.as_ref())
|
||||
.await;
|
||||
|
||||
if !input.is_empty() {
|
||||
let input_item = ResponseInputItem::from(input);
|
||||
let response_item = ResponseItem::from(input_item);
|
||||
sess.record_conversation_items(turn_context.as_ref(), std::slice::from_ref(&response_item))
|
||||
.await;
|
||||
}
|
||||
|
||||
let prompt_input = sess
|
||||
.clone_history()
|
||||
.await
|
||||
.for_prompt(&turn_context.model_info.input_modalities);
|
||||
let router = built_tools(
|
||||
sess,
|
||||
turn_context.as_ref(),
|
||||
&prompt_input,
|
||||
&HashSet::new(),
|
||||
Some(turn_context.turn_skills.outcome.as_ref()),
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.await?;
|
||||
let base_instructions = sess.get_base_instructions().await;
|
||||
let prompt = build_prompt(
|
||||
prompt_input,
|
||||
router.as_ref(),
|
||||
turn_context.as_ref(),
|
||||
base_instructions,
|
||||
);
|
||||
|
||||
Ok(prompt.get_formatted_input())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use crate::config::test_config;
|
||||
|
||||
use super::build_prompt_input;
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_prompt_input_includes_context_and_user_message() {
|
||||
let codex_home = tempfile::tempdir().expect("create codex home");
|
||||
let cwd = tempfile::tempdir().expect("create cwd");
|
||||
let mut config = test_config();
|
||||
config.codex_home = codex_home.path().to_path_buf();
|
||||
config.cwd = AbsolutePathBuf::try_from(cwd.path().to_path_buf()).expect("absolute cwd");
|
||||
config.user_instructions = Some("Project-specific test instructions".to_string());
|
||||
|
||||
let input = build_prompt_input(
|
||||
config,
|
||||
vec![UserInput::Text {
|
||||
text: "hello from debug prompt".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
)
|
||||
.await
|
||||
.expect("build prompt input");
|
||||
|
||||
let expected_user_message = ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "hello from debug prompt".to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
};
|
||||
assert_eq!(input.last(), Some(&expected_user_message));
|
||||
assert!(input.iter().any(|item| {
|
||||
let ResponseItem::Message { content, .. } = item else {
|
||||
return false;
|
||||
};
|
||||
|
||||
content.iter().any(|content_item| {
|
||||
let (ContentItem::InputText { text } | ContentItem::OutputText { text }) =
|
||||
content_item
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
text.contains("Project-specific test instructions")
|
||||
})
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,16 @@ use crate::agent::next_thread_spawn_depth;
|
||||
use crate::agent::role::DEFAULT_ROLE_NAME;
|
||||
use crate::agent::role::apply_role_to_config;
|
||||
use codex_protocol::AgentPath;
|
||||
use codex_protocol::models::DeveloperInstructions;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
use codex_protocol::protocol::Op;
|
||||
|
||||
pub(crate) struct Handler;
|
||||
|
||||
pub(crate) const SPAWN_AGENT_DEVELOPER_INSTRUCTIONS: &str = r#"<spawned_agent_context>
|
||||
You are a newly spawned agent in a team of agents collaborating to complete a task. You can spawn sub-agents to handle subtasks, and those sub-agents can spawn their own sub-agents. You are responsible for returning the response to your assigned task in the final channel. When you give your response, the contents of your response in the final channel will be immediately delivered back to your parent agent. The prior conversation history was forked from your parent agent. Treat the next user message as your assigned task, and use the forked history only as background context.
|
||||
</spawned_agent_context>"#;
|
||||
|
||||
impl ToolHandler for Handler {
|
||||
type Output = SpawnAgentResult;
|
||||
|
||||
@@ -78,6 +83,17 @@ impl ToolHandler for Handler {
|
||||
.map_err(FunctionCallError::RespondToModel)?;
|
||||
apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?;
|
||||
apply_spawn_agent_overrides(&mut config, child_depth);
|
||||
config.developer_instructions = Some(
|
||||
if let Some(existing_instructions) = config.developer_instructions.take() {
|
||||
DeveloperInstructions::new(existing_instructions)
|
||||
.concat(DeveloperInstructions::new(
|
||||
SPAWN_AGENT_DEVELOPER_INSTRUCTIONS,
|
||||
))
|
||||
.into_text()
|
||||
} else {
|
||||
DeveloperInstructions::new(SPAWN_AGENT_DEVELOPER_INSTRUCTIONS).into_text()
|
||||
},
|
||||
);
|
||||
|
||||
let spawn_source = thread_spawn_source(
|
||||
session.conversation_id,
|
||||
|
||||
@@ -23,6 +23,14 @@ use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
use wiremock::matchers::header;
|
||||
|
||||
fn normalize_git_remote_url(url: &str) -> String {
|
||||
let normalized = url.trim().trim_end_matches('/');
|
||||
normalized
|
||||
.strip_suffix(".git")
|
||||
.unwrap_or(normalized)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn responses_stream_includes_subagent_header_on_review() {
|
||||
core_test_support::skip_if_no_network!();
|
||||
@@ -129,10 +137,16 @@ async fn responses_stream_includes_subagent_header_on_review() {
|
||||
}
|
||||
|
||||
let request = request_recorder.single_request();
|
||||
let expected_window_id = format!("{conversation_id}:0");
|
||||
assert_eq!(
|
||||
request.header("x-openai-subagent").as_deref(),
|
||||
Some("review")
|
||||
);
|
||||
assert_eq!(
|
||||
request.header("x-codex-window-id").as_deref(),
|
||||
Some(expected_window_id.as_str())
|
||||
);
|
||||
assert_eq!(request.header("x-codex-parent-thread-id"), None);
|
||||
assert_eq!(request.header("x-codex-sandbox"), None);
|
||||
}
|
||||
|
||||
@@ -534,13 +548,15 @@ async fn responses_stream_includes_turn_metadata_header_for_git_workspace_e2e()
|
||||
.and_then(serde_json::Value::as_str),
|
||||
Some(expected_head.as_str())
|
||||
);
|
||||
let actual_origin = workspace
|
||||
.get("associated_remote_urls")
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.and_then(|remotes| remotes.get("origin"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.expect("origin remote should be present");
|
||||
assert_eq!(
|
||||
workspace
|
||||
.get("associated_remote_urls")
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.and_then(|remotes| remotes.get("origin"))
|
||||
.and_then(serde_json::Value::as_str),
|
||||
Some(expected_origin.as_str())
|
||||
normalize_git_remote_url(actual_origin),
|
||||
normalize_git_remote_url(&expected_origin)
|
||||
);
|
||||
assert_eq!(
|
||||
workspace
|
||||
|
||||
@@ -47,6 +47,7 @@ use codex_protocol::user_input::UserInput;
|
||||
use core_test_support::PathBufExt;
|
||||
use core_test_support::apps_test_server::AppsTestServer;
|
||||
use core_test_support::load_default_config_for_test;
|
||||
use core_test_support::responses::ResponsesRequest;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_completed_with_tokens;
|
||||
use core_test_support::responses::ev_message_item_added;
|
||||
@@ -95,6 +96,13 @@ fn message_input_texts(item: &serde_json::Value) -> Vec<&str> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn message_input_text_contains(request: &ResponsesRequest, role: &str, needle: &str) -> bool {
|
||||
request
|
||||
.message_input_texts(role)
|
||||
.iter()
|
||||
.any(|text| text.contains(needle))
|
||||
}
|
||||
|
||||
/// Writes an `auth.json` into the provided `codex_home` with the specified parameters.
|
||||
/// Returns the fake JWT string written to `tokens.id_token`.
|
||||
#[expect(clippy::unwrap_used)]
|
||||
@@ -187,23 +195,28 @@ mv tokens.next tokens.txt
|
||||
|
||||
#[cfg(windows)]
|
||||
let (command, args) = {
|
||||
let script_path = tempdir.path().join("print-token.ps1");
|
||||
let script_path = tempdir.path().join("print-token.cmd");
|
||||
std::fs::write(
|
||||
&script_path,
|
||||
r#"$lines = @(Get-Content -Path tokens.txt)
|
||||
if ($lines.Count -eq 0) { exit 1 }
|
||||
Write-Output $lines[0]
|
||||
$lines | Select-Object -Skip 1 | Set-Content -Path tokens.txt
|
||||
r#"@echo off
|
||||
setlocal EnableExtensions DisableDelayedExpansion
|
||||
|
||||
set "first_line="
|
||||
<tokens.txt set /p first_line=
|
||||
if not defined first_line exit /b 1
|
||||
|
||||
echo(%first_line%
|
||||
more +1 tokens.txt > tokens.next
|
||||
move /y tokens.next tokens.txt >nul
|
||||
"#,
|
||||
)?;
|
||||
(
|
||||
"powershell.exe".to_string(),
|
||||
"cmd.exe".to_string(),
|
||||
vec![
|
||||
"-NoProfile".to_string(),
|
||||
"-ExecutionPolicy".to_string(),
|
||||
"Bypass".to_string(),
|
||||
"-File".to_string(),
|
||||
".\\print-token.ps1".to_string(),
|
||||
"/D".to_string(),
|
||||
"/Q".to_string(),
|
||||
"/C".to_string(),
|
||||
".\\print-token.cmd".to_string(),
|
||||
],
|
||||
)
|
||||
};
|
||||
@@ -219,7 +232,8 @@ $lines | Select-Object -Skip 1 | Set-Content -Path tokens.txt
|
||||
ModelProviderAuthInfo {
|
||||
command: self.command.clone(),
|
||||
args: self.args.clone(),
|
||||
timeout_ms: non_zero_u64(/*value*/ 1_000),
|
||||
// Match the provider-auth default to avoid brittle shell-startup timing in CI.
|
||||
timeout_ms: non_zero_u64(/*value*/ 5_000),
|
||||
refresh_interval_ms: 60_000,
|
||||
cwd: match codex_utils_absolute_path::AbsolutePathBuf::try_from(self.tempdir.path()) {
|
||||
Ok(cwd) => cwd,
|
||||
@@ -1208,47 +1222,19 @@ async fn includes_apps_guidance_as_developer_message_for_chatgpt_auth() {
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let request = resp_mock.single_request();
|
||||
let request_body = request.body_json();
|
||||
let input = request_body["input"].as_array().expect("input array");
|
||||
let apps_snippet =
|
||||
"Apps (Connectors) can be explicitly triggered in user messages in the format";
|
||||
|
||||
let has_developer_apps_guidance = input.iter().any(|item| {
|
||||
item.get("role").and_then(|value| value.as_str()) == Some("developer")
|
||||
&& item
|
||||
.get("content")
|
||||
.and_then(|value| value.as_array())
|
||||
.is_some_and(|content| {
|
||||
content.iter().any(|entry| {
|
||||
entry
|
||||
.get("text")
|
||||
.and_then(|value| value.as_str())
|
||||
.is_some_and(|text| text.contains(apps_snippet))
|
||||
})
|
||||
})
|
||||
});
|
||||
assert!(
|
||||
has_developer_apps_guidance,
|
||||
"expected apps guidance in a developer message, got {input:#?}"
|
||||
message_input_text_contains(&request, "developer", apps_snippet),
|
||||
"expected apps guidance in a developer message, got {:?}",
|
||||
request.body_json()["input"]
|
||||
);
|
||||
|
||||
let has_user_apps_guidance = input.iter().any(|item| {
|
||||
item.get("role").and_then(|value| value.as_str()) == Some("user")
|
||||
&& item
|
||||
.get("content")
|
||||
.and_then(|value| value.as_array())
|
||||
.is_some_and(|content| {
|
||||
content.iter().any(|entry| {
|
||||
entry
|
||||
.get("text")
|
||||
.and_then(|value| value.as_str())
|
||||
.is_some_and(|text| text.contains(apps_snippet))
|
||||
})
|
||||
})
|
||||
});
|
||||
assert!(
|
||||
!has_user_apps_guidance,
|
||||
"did not expect apps guidance in user messages, got {input:#?}"
|
||||
!message_input_text_contains(&request, "user", apps_snippet),
|
||||
"did not expect apps guidance in user messages, got {:?}",
|
||||
request.body_json()["input"]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1296,26 +1282,105 @@ async fn omits_apps_guidance_for_api_key_auth_even_when_feature_enabled() {
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let request = resp_mock.single_request();
|
||||
let request_body = request.body_json();
|
||||
let input = request_body["input"].as_array().expect("input array");
|
||||
let apps_snippet =
|
||||
"Apps (Connectors) can be explicitly triggered in user messages in the format";
|
||||
|
||||
let has_apps_guidance = input.iter().any(|item| {
|
||||
item.get("content")
|
||||
.and_then(|value| value.as_array())
|
||||
.is_some_and(|content| {
|
||||
content.iter().any(|entry| {
|
||||
entry
|
||||
.get("text")
|
||||
.and_then(|value| value.as_str())
|
||||
.is_some_and(|text| text.contains(apps_snippet))
|
||||
})
|
||||
})
|
||||
});
|
||||
assert!(
|
||||
!has_apps_guidance,
|
||||
"did not expect apps guidance for API key auth, got {input:#?}"
|
||||
!message_input_text_contains(&request, "developer", apps_snippet)
|
||||
&& !message_input_text_contains(&request, "user", apps_snippet),
|
||||
"did not expect apps guidance for API key auth, got {:?}",
|
||||
request.body_json()["input"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn omits_apps_guidance_when_configured_off() {
|
||||
skip_if_no_network!();
|
||||
let server = MockServer::start().await;
|
||||
let apps_server = AppsTestServer::mount(&server)
|
||||
.await
|
||||
.expect("mount apps MCP mock");
|
||||
let apps_base_url = apps_server.chatgpt_base_url.clone();
|
||||
|
||||
let resp_mock = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![ev_response_created("resp1"), ev_completed("resp1")]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex()
|
||||
.with_auth(create_dummy_codex_auth())
|
||||
.with_config(move |config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::Apps)
|
||||
.expect("test config should allow feature update");
|
||||
config.chatgpt_base_url = apps_base_url;
|
||||
config.include_apps_instructions = false;
|
||||
});
|
||||
let codex = builder
|
||||
.build(&server)
|
||||
.await
|
||||
.expect("create new conversation")
|
||||
.codex;
|
||||
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let request = resp_mock.single_request();
|
||||
assert!(
|
||||
!message_input_text_contains(&request, "developer", "<apps_instructions>"),
|
||||
"did not expect apps instructions when include_apps_instructions = false, got {:?}",
|
||||
request.body_json()["input"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn omits_environment_context_when_configured_off() {
|
||||
let server = MockServer::start().await;
|
||||
let resp_mock = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![ev_response_created("resp1"), ev_completed("resp1")]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex().with_config(|config| {
|
||||
config.include_environment_context = false;
|
||||
});
|
||||
let codex = builder
|
||||
.build(&server)
|
||||
.await
|
||||
.expect("create new conversation")
|
||||
.codex;
|
||||
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let request = resp_mock.single_request();
|
||||
assert!(
|
||||
!message_input_text_contains(&request, "user", "<environment_context>"),
|
||||
"did not expect environment context when include_environment_context = false, got {:?}",
|
||||
request.body_json()["input"]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -154,3 +154,4 @@ mod user_shell_cmd;
|
||||
mod view_image;
|
||||
mod web_search;
|
||||
mod websocket_fallback;
|
||||
mod window_headers;
|
||||
|
||||
@@ -1,17 +1,31 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_core::CodexThread;
|
||||
use codex_protocol::AgentPath;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use core_test_support::context_snapshot;
|
||||
use core_test_support::context_snapshot::ContextSnapshotOptions;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_function_call;
|
||||
use core_test_support::responses::ev_message_item_added;
|
||||
use core_test_support::responses::ev_output_text_delta;
|
||||
use core_test_support::responses::ev_reasoning_item;
|
||||
use core_test_support::responses::ev_reasoning_item_added;
|
||||
use core_test_support::responses::ev_response_created;
|
||||
use core_test_support::streaming_sse::StreamingSseChunk;
|
||||
use core_test_support::streaming_sse::StreamingSseServer;
|
||||
use core_test_support::streaming_sse::start_streaming_sse_server;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use serde_json::from_slice;
|
||||
use serde_json::json;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
fn ev_message_item_done(id: &str, text: &str) -> Value {
|
||||
@@ -44,6 +58,115 @@ fn message_input_texts(body: &Value, role: &str) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn chunk(event: Value) -> StreamingSseChunk {
|
||||
StreamingSseChunk {
|
||||
gate: None,
|
||||
body: responses::sse(vec![event]),
|
||||
}
|
||||
}
|
||||
|
||||
fn gated_chunk(gate: oneshot::Receiver<()>, events: Vec<Value>) -> StreamingSseChunk {
|
||||
StreamingSseChunk {
|
||||
gate: Some(gate),
|
||||
body: responses::sse(events),
|
||||
}
|
||||
}
|
||||
|
||||
fn response_completed_chunks(response_id: &str) -> Vec<StreamingSseChunk> {
|
||||
vec![
|
||||
chunk(ev_response_created(response_id)),
|
||||
chunk(ev_completed(response_id)),
|
||||
]
|
||||
}
|
||||
|
||||
async fn build_codex(server: &StreamingSseServer) -> Arc<CodexThread> {
|
||||
test_codex()
|
||||
.with_model("gpt-5.1")
|
||||
.build_with_streaming_server(server)
|
||||
.await
|
||||
.unwrap_or_else(|err| panic!("build streaming Codex test session: {err}"))
|
||||
.codex
|
||||
}
|
||||
|
||||
async fn submit_user_input(codex: &CodexThread, text: &str) {
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: text.to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|err| panic!("submit user input: {err}"));
|
||||
}
|
||||
|
||||
async fn submit_queue_only_agent_mail(codex: &CodexThread, text: &str) {
|
||||
codex
|
||||
.submit(Op::InterAgentCommunication {
|
||||
communication: InterAgentCommunication::new(
|
||||
AgentPath::try_from("/root/worker")
|
||||
.unwrap_or_else(|err| panic!("worker path should parse: {err}")),
|
||||
AgentPath::root(),
|
||||
Vec::new(),
|
||||
text.to_string(),
|
||||
/*trigger_turn*/ false,
|
||||
),
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|err| panic!("submit queue-only agent mail: {err}"));
|
||||
}
|
||||
|
||||
async fn wait_for_reasoning_item_started(codex: &CodexThread) {
|
||||
wait_for_event(codex, |event| {
|
||||
matches!(
|
||||
event,
|
||||
EventMsg::ItemStarted(item_started)
|
||||
if matches!(&item_started.item, TurnItem::Reasoning(_))
|
||||
)
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn wait_for_agent_message(codex: &CodexThread, text: &str) {
|
||||
let final_message = wait_for_event(
|
||||
codex,
|
||||
|event| matches!(event, EventMsg::AgentMessage(message) if message.message == text),
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(final_message, EventMsg::AgentMessage(_)));
|
||||
}
|
||||
|
||||
async fn wait_for_turn_complete(codex: &CodexThread) {
|
||||
wait_for_event(codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
|
||||
}
|
||||
|
||||
fn assert_two_responses_input_snapshot(snapshot_name: &str, requests: &[Vec<u8>]) {
|
||||
assert_eq!(requests.len(), 2);
|
||||
let options = ContextSnapshotOptions::default().strip_capability_instructions();
|
||||
let first: Value =
|
||||
from_slice(&requests[0]).unwrap_or_else(|err| panic!("parse first request: {err}"));
|
||||
let second: Value =
|
||||
from_slice(&requests[1]).unwrap_or_else(|err| panic!("parse second request: {err}"));
|
||||
let first_items = first["input"]
|
||||
.as_array()
|
||||
.unwrap_or_else(|| panic!("first request input"))
|
||||
.clone();
|
||||
let second_items = second["input"]
|
||||
.as_array()
|
||||
.unwrap_or_else(|| panic!("second request input"))
|
||||
.clone();
|
||||
let snapshot = context_snapshot::format_labeled_items_snapshot(
|
||||
"/responses POST bodies (input only, redacted like other suite snapshots)",
|
||||
&[
|
||||
("First request", first_items.as_slice()),
|
||||
("Second request", second_items.as_slice()),
|
||||
],
|
||||
&options,
|
||||
);
|
||||
insta::assert_snapshot!(snapshot_name, snapshot);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "TODO(aibrahim): flaky"]
|
||||
async fn injected_user_input_triggers_follow_up_request_with_deltas() {
|
||||
@@ -144,3 +267,162 @@ async fn injected_user_input_triggers_follow_up_request_with_deltas() {
|
||||
|
||||
server.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn queued_inter_agent_mail_triggers_follow_up_after_reasoning_item() {
|
||||
let (gate_reasoning_done_tx, gate_reasoning_done_rx) = oneshot::channel();
|
||||
|
||||
let first_chunks = vec![
|
||||
chunk(ev_response_created("resp-1")),
|
||||
chunk(ev_reasoning_item_added("reason-1", &["thinking"])),
|
||||
gated_chunk(
|
||||
gate_reasoning_done_rx,
|
||||
vec![
|
||||
ev_reasoning_item("reason-1", &["thinking"], &[]),
|
||||
ev_function_call(
|
||||
"call-stale",
|
||||
"shell",
|
||||
r#"{"command":"echo stale tool call"}"#,
|
||||
),
|
||||
ev_message_item_added("msg-stale", ""),
|
||||
ev_output_text_delta("stale final"),
|
||||
ev_message_item_done("msg-stale", "stale final"),
|
||||
ev_completed("resp-1"),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
let (server, _completions) =
|
||||
start_streaming_sse_server(vec![first_chunks, response_completed_chunks("resp-2")]).await;
|
||||
|
||||
let codex = build_codex(&server).await;
|
||||
|
||||
submit_user_input(&codex, "first prompt").await;
|
||||
|
||||
wait_for_reasoning_item_started(&codex).await;
|
||||
|
||||
submit_queue_only_agent_mail(&codex, "queued child update").await;
|
||||
|
||||
let _ = gate_reasoning_done_tx.send(());
|
||||
|
||||
wait_for_turn_complete(&codex).await;
|
||||
|
||||
let requests = server.requests().await;
|
||||
assert_two_responses_input_snapshot("pending_input_queued_mail_after_reasoning", &requests);
|
||||
|
||||
server.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn queued_inter_agent_mail_triggers_follow_up_after_commentary_message_item() {
|
||||
let (gate_message_done_tx, gate_message_done_rx) = oneshot::channel();
|
||||
|
||||
let first_chunks = vec![
|
||||
chunk(ev_response_created("resp-1")),
|
||||
chunk(ev_message_item_added("msg-1", "")),
|
||||
gated_chunk(
|
||||
gate_message_done_rx,
|
||||
vec![
|
||||
ev_output_text_delta("first answer"),
|
||||
json!({
|
||||
"type": "response.output_item.done",
|
||||
"item": {
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"id": "msg-1",
|
||||
"content": [{"type": "output_text", "text": "first answer"}],
|
||||
"phase": "commentary",
|
||||
}
|
||||
}),
|
||||
ev_function_call(
|
||||
"call-stale",
|
||||
"shell",
|
||||
r#"{"command":"echo stale tool call"}"#,
|
||||
),
|
||||
ev_message_item_added("msg-stale", ""),
|
||||
ev_output_text_delta("stale final"),
|
||||
ev_message_item_done("msg-stale", "stale final"),
|
||||
ev_completed("resp-1"),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
let (server, _completions) =
|
||||
start_streaming_sse_server(vec![first_chunks, response_completed_chunks("resp-2")]).await;
|
||||
|
||||
let codex = build_codex(&server).await;
|
||||
|
||||
submit_user_input(&codex, "first prompt").await;
|
||||
|
||||
wait_for_event(&codex, |event| {
|
||||
matches!(
|
||||
event,
|
||||
EventMsg::ItemStarted(item_started)
|
||||
if matches!(&item_started.item, TurnItem::AgentMessage(_))
|
||||
)
|
||||
})
|
||||
.await;
|
||||
|
||||
submit_queue_only_agent_mail(&codex, "queued child update").await;
|
||||
|
||||
let _ = gate_message_done_tx.send(());
|
||||
|
||||
wait_for_agent_message(&codex, "first answer").await;
|
||||
|
||||
wait_for_turn_complete(&codex).await;
|
||||
|
||||
let requests = server.requests().await;
|
||||
assert_two_responses_input_snapshot("pending_input_queued_mail_after_commentary", &requests);
|
||||
|
||||
server.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn user_input_does_not_preempt_after_reasoning_item() {
|
||||
let (gate_reasoning_done_tx, gate_reasoning_done_rx) = oneshot::channel();
|
||||
|
||||
let first_chunks = vec![
|
||||
chunk(ev_response_created("resp-1")),
|
||||
chunk(ev_reasoning_item_added("reason-1", &["thinking"])),
|
||||
gated_chunk(
|
||||
gate_reasoning_done_rx,
|
||||
vec![
|
||||
ev_reasoning_item("reason-1", &["thinking"], &[]),
|
||||
ev_function_call(
|
||||
"call-preserved",
|
||||
"shell",
|
||||
r#"{"command":"echo preserved tool call"}"#,
|
||||
),
|
||||
ev_message_item_added("msg-1", ""),
|
||||
ev_output_text_delta("first answer"),
|
||||
ev_message_item_done("msg-1", "first answer"),
|
||||
ev_completed("resp-1"),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
let (server, _completions) =
|
||||
start_streaming_sse_server(vec![first_chunks, response_completed_chunks("resp-2")]).await;
|
||||
|
||||
let codex = build_codex(&server).await;
|
||||
|
||||
submit_user_input(&codex, "first prompt").await;
|
||||
|
||||
wait_for_reasoning_item_started(&codex).await;
|
||||
|
||||
submit_user_input(&codex, "second prompt").await;
|
||||
|
||||
let _ = gate_reasoning_done_tx.send(());
|
||||
|
||||
wait_for_agent_message(&codex, "first answer").await;
|
||||
|
||||
wait_for_turn_complete(&codex).await;
|
||||
|
||||
let requests = server.requests().await;
|
||||
assert_two_responses_input_snapshot(
|
||||
"pending_input_user_input_no_preempt_after_reasoning",
|
||||
&requests,
|
||||
);
|
||||
|
||||
server.shutdown().await;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use core_test_support::responses::ResponsesRequest;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_response_created;
|
||||
use core_test_support::responses::mount_sse_once;
|
||||
@@ -21,26 +22,11 @@ use pretty_assertions::assert_eq;
|
||||
use std::collections::HashSet;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn permissions_texts(input: &[serde_json::Value]) -> Vec<String> {
|
||||
input
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
let role = item.get("role")?.as_str()?;
|
||||
if role != "developer" {
|
||||
return None;
|
||||
}
|
||||
let text = item
|
||||
.get("content")?
|
||||
.as_array()?
|
||||
.first()?
|
||||
.get("text")?
|
||||
.as_str()?;
|
||||
if text.contains("<permissions instructions>") {
|
||||
Some(text.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
fn permissions_texts(request: &ResponsesRequest) -> Vec<String> {
|
||||
request
|
||||
.message_input_texts("developer")
|
||||
.into_iter()
|
||||
.filter(|text| text.contains("<permissions instructions>"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -71,11 +57,7 @@ async fn permissions_message_sent_once_on_start() -> Result<()> {
|
||||
.await?;
|
||||
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let request = req.single_request();
|
||||
let body = request.body_json();
|
||||
let input = body["input"].as_array().expect("input array");
|
||||
let permissions = permissions_texts(input);
|
||||
assert_eq!(permissions.len(), 1);
|
||||
assert_eq!(permissions_texts(&req.single_request()).len(), 1);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -139,12 +121,8 @@ async fn permissions_message_added_on_override_change() -> Result<()> {
|
||||
.await?;
|
||||
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let body1 = req1.single_request().body_json();
|
||||
let body2 = req2.single_request().body_json();
|
||||
let input1 = body1["input"].as_array().expect("input array");
|
||||
let input2 = body2["input"].as_array().expect("input array");
|
||||
let permissions_1 = permissions_texts(input1);
|
||||
let permissions_2 = permissions_texts(input2);
|
||||
let permissions_1 = permissions_texts(&req1.single_request());
|
||||
let permissions_2 = permissions_texts(&req2.single_request());
|
||||
|
||||
assert_eq!(permissions_1.len(), 1);
|
||||
assert_eq!(permissions_2.len(), 2);
|
||||
@@ -197,12 +175,8 @@ async fn permissions_message_not_added_when_no_change() -> Result<()> {
|
||||
.await?;
|
||||
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let body1 = req1.single_request().body_json();
|
||||
let body2 = req2.single_request().body_json();
|
||||
let input1 = body1["input"].as_array().expect("input array");
|
||||
let input2 = body2["input"].as_array().expect("input array");
|
||||
let permissions_1 = permissions_texts(input1);
|
||||
let permissions_2 = permissions_texts(input2);
|
||||
let permissions_1 = permissions_texts(&req1.single_request());
|
||||
let permissions_2 = permissions_texts(&req2.single_request());
|
||||
|
||||
assert_eq!(permissions_1.len(), 1);
|
||||
assert_eq!(permissions_2.len(), 1);
|
||||
@@ -211,6 +185,78 @@ async fn permissions_message_not_added_when_no_change() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn permissions_message_omitted_when_disabled() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let req1 = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
|
||||
)
|
||||
.await;
|
||||
let req2 = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex().with_config(move |config| {
|
||||
config.include_permissions_instructions = false;
|
||||
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
test.codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello 1".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
test.codex
|
||||
.submit(Op::OverrideTurnContext {
|
||||
cwd: None,
|
||||
approval_policy: Some(AskForApproval::Never),
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: None,
|
||||
windows_sandbox_level: None,
|
||||
model: None,
|
||||
effort: None,
|
||||
summary: None,
|
||||
service_tier: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
test.codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello 2".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
assert_eq!(
|
||||
permissions_texts(&req1.single_request()),
|
||||
Vec::<String>::new()
|
||||
);
|
||||
assert_eq!(
|
||||
permissions_texts(&req2.single_request()),
|
||||
Vec::<String>::new()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn resume_replays_permissions_messages() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
@@ -297,9 +343,7 @@ async fn resume_replays_permissions_messages() -> Result<()> {
|
||||
.await?;
|
||||
wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let body3 = req3.single_request().body_json();
|
||||
let input = body3["input"].as_array().expect("input array");
|
||||
let permissions = permissions_texts(input);
|
||||
let permissions = permissions_texts(&req3.single_request());
|
||||
assert_eq!(permissions.len(), 3);
|
||||
let unique = permissions.into_iter().collect::<HashSet<String>>();
|
||||
assert_eq!(unique.len(), 2);
|
||||
@@ -385,9 +429,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> {
|
||||
.await?;
|
||||
wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let body2 = req2.single_request().body_json();
|
||||
let input2 = body2["input"].as_array().expect("input array");
|
||||
let permissions_base = permissions_texts(input2);
|
||||
let permissions_base = permissions_texts(&req2.single_request());
|
||||
assert_eq!(permissions_base.len(), 2);
|
||||
|
||||
builder = builder.with_config(|config| {
|
||||
@@ -406,9 +448,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> {
|
||||
.await?;
|
||||
wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let body3 = req3.single_request().body_json();
|
||||
let input3 = body3["input"].as_array().expect("input array");
|
||||
let permissions_resume = permissions_texts(input3);
|
||||
let permissions_resume = permissions_texts(&req3.single_request());
|
||||
assert_eq!(permissions_resume.len(), permissions_base.len() + 1);
|
||||
assert_eq!(
|
||||
&permissions_resume[..permissions_base.len()],
|
||||
@@ -440,9 +480,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> {
|
||||
.await?;
|
||||
wait_for_event(&forked.thread, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let body4 = req4.single_request().body_json();
|
||||
let input4 = body4["input"].as_array().expect("input array");
|
||||
let permissions_fork = permissions_texts(input4);
|
||||
let permissions_fork = permissions_texts(&req4.single_request());
|
||||
assert_eq!(permissions_fork.len(), permissions_base.len() + 1);
|
||||
assert_eq!(
|
||||
&permissions_fork[..permissions_base.len()],
|
||||
@@ -494,9 +532,7 @@ async fn permissions_message_includes_writable_roots() -> Result<()> {
|
||||
.await?;
|
||||
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let body = req.single_request().body_json();
|
||||
let input = body["input"].as_array().expect("input array");
|
||||
let permissions = permissions_texts(input);
|
||||
let permissions = permissions_texts(&req.single_request());
|
||||
let expected = DeveloperInstructions::from_policy(
|
||||
&sandbox_policy,
|
||||
AskForApproval::OnRequest,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
source: core/tests/suite/pending_input.rs
|
||||
expression: snapshot
|
||||
---
|
||||
Scenario: /responses POST bodies (input only, redacted like other suite snapshots)
|
||||
|
||||
## First request
|
||||
00:message/developer:<PERMISSIONS_INSTRUCTIONS>
|
||||
01:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
|
||||
02:message/user:first prompt
|
||||
|
||||
## Second request
|
||||
00:message/developer:<PERMISSIONS_INSTRUCTIONS>
|
||||
01:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
|
||||
02:message/user:first prompt
|
||||
03:message/assistant:first answer
|
||||
04:message/assistant:{"author":"/root/worker","recipient":"/root","other_recipients":[],"content":"queued child update","trigger_turn":false}
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
source: core/tests/suite/pending_input.rs
|
||||
expression: snapshot
|
||||
---
|
||||
Scenario: /responses POST bodies (input only, redacted like other suite snapshots)
|
||||
|
||||
## First request
|
||||
00:message/developer:<PERMISSIONS_INSTRUCTIONS>
|
||||
01:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
|
||||
02:message/user:first prompt
|
||||
|
||||
## Second request
|
||||
00:message/developer:<PERMISSIONS_INSTRUCTIONS>
|
||||
01:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
|
||||
02:message/user:first prompt
|
||||
03:reasoning:summary=thinking:encrypted=true
|
||||
04:message/assistant:{"author":"/root/worker","recipient":"/root","other_recipients":[],"content":"queued child update","trigger_turn":false}
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
source: core/tests/suite/pending_input.rs
|
||||
expression: snapshot
|
||||
---
|
||||
Scenario: /responses POST bodies (input only, redacted like other suite snapshots)
|
||||
|
||||
## First request
|
||||
00:message/developer:<PERMISSIONS_INSTRUCTIONS>
|
||||
01:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
|
||||
02:message/user:first prompt
|
||||
|
||||
## Second request
|
||||
00:message/developer:<PERMISSIONS_INSTRUCTIONS>
|
||||
01:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
|
||||
02:message/user:first prompt
|
||||
03:reasoning:summary=thinking:encrypted=true
|
||||
04:function_call/shell
|
||||
05:message/assistant:first answer
|
||||
06:function_call_output:failed to parse function arguments: invalid type: string "echo preserved tool call", expected a sequence at line 1 column 37
|
||||
07:message/user:second prompt
|
||||
@@ -35,6 +35,7 @@ const REQUESTED_MODEL: &str = "gpt-5.1";
|
||||
const REQUESTED_REASONING_EFFORT: ReasoningEffort = ReasoningEffort::Low;
|
||||
const ROLE_MODEL: &str = "gpt-5.1-codex-max";
|
||||
const ROLE_REASONING_EFFORT: ReasoningEffort = ReasoningEffort::High;
|
||||
const SPAWNED_AGENT_DEVELOPER_INSTRUCTIONS: &str = "You are a newly spawned agent in a team of agents collaborating to complete a task. You can spawn sub-agents to handle subtasks, and those sub-agents can spawn their own sub-agents. You are responsible for returning the response to your assigned task in the final channel. When you give your response, the contents of your response in the final channel will be immediately delivered back to your parent agent. The prior conversation history was forked from your parent agent. Treat the next user message as your assigned task, and use the forked history only as background context.";
|
||||
|
||||
fn body_contains(req: &wiremock::Request, text: &str) -> bool {
|
||||
let is_zstd = req
|
||||
@@ -413,6 +414,99 @@ async fn spawn_agent_requested_model_and_reasoning_override_inherited_settings_w
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn spawned_multi_agent_v2_child_receives_xml_tagged_developer_context() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let spawn_args = serde_json::to_string(&json!({
|
||||
"message": CHILD_PROMPT,
|
||||
"task_name": "worker",
|
||||
}))?;
|
||||
mount_sse_once_match(
|
||||
&server,
|
||||
|req: &wiremock::Request| body_contains(req, TURN_1_PROMPT),
|
||||
sse(vec![
|
||||
ev_response_created("resp-turn1-1"),
|
||||
ev_function_call(SPAWN_CALL_ID, "spawn_agent", &spawn_args),
|
||||
ev_completed("resp-turn1-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let _child_request_log = mount_sse_once_match(
|
||||
&server,
|
||||
|req: &wiremock::Request| {
|
||||
body_contains(req, CHILD_PROMPT) && !body_contains(req, SPAWN_CALL_ID)
|
||||
},
|
||||
sse(vec![
|
||||
ev_response_created("resp-child-1"),
|
||||
ev_completed("resp-child-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let _turn1_followup = mount_sse_once_match(
|
||||
&server,
|
||||
|req: &wiremock::Request| body_contains(req, SPAWN_CALL_ID),
|
||||
sse(vec![
|
||||
ev_response_created("resp-turn1-2"),
|
||||
ev_assistant_message("msg-turn1-2", "parent done"),
|
||||
ev_completed("resp-turn1-2"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex().with_config(|config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::Collab)
|
||||
.expect("test config should allow feature update");
|
||||
config
|
||||
.features
|
||||
.enable(Feature::MultiAgentV2)
|
||||
.expect("test config should allow feature update");
|
||||
config.developer_instructions = Some("Parent developer instructions.".to_string());
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
test.submit_turn(TURN_1_PROMPT).await?;
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
let child_request = loop {
|
||||
if let Some(request) = server
|
||||
.received_requests()
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.find(|request| {
|
||||
body_contains(request, CHILD_PROMPT)
|
||||
&& body_contains(request, "<spawned_agent_context>")
|
||||
&& body_contains(request, SPAWNED_AGENT_DEVELOPER_INSTRUCTIONS)
|
||||
&& !body_contains(request, SPAWN_CALL_ID)
|
||||
})
|
||||
{
|
||||
break request;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
anyhow::bail!("timed out waiting for spawned child request with developer context");
|
||||
}
|
||||
sleep(Duration::from_millis(10)).await;
|
||||
};
|
||||
assert!(body_contains(
|
||||
&child_request,
|
||||
"Parent developer instructions."
|
||||
));
|
||||
assert!(body_contains(&child_request, "<spawned_agent_context>"));
|
||||
assert!(body_contains(
|
||||
&child_request,
|
||||
SPAWNED_AGENT_DEVELOPER_INSTRUCTIONS
|
||||
));
|
||||
assert!(body_contains(&child_request, CHILD_PROMPT));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn spawn_agent_role_overrides_requested_model_and_reasoning_settings() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
146
codex-rs/core/tests/suite/window_headers.rs
Normal file
146
codex-rs/core/tests/suite/window_headers.rs
Normal file
@@ -0,0 +1,146 @@
|
||||
#![allow(clippy::expect_used)]
|
||||
|
||||
use super::compact::COMPACT_WARNING_MESSAGE;
|
||||
use anyhow::Result;
|
||||
use codex_core::CodexThread;
|
||||
use codex_core::compact::SUMMARIZATION_PROMPT;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::WarningEvent;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use core_test_support::responses::ResponsesRequest;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::mount_sse_sequence;
|
||||
use core_test_support::responses::sse;
|
||||
use core_test_support::responses::start_mock_server;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn window_id_advances_after_compact_persists_on_resume_and_resets_on_fork() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let request_log = mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "first reply"),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-2", "summary"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
sse(vec![ev_completed("resp-3")]),
|
||||
sse(vec![ev_completed("resp-4")]),
|
||||
sse(vec![ev_completed("resp-5")]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex().with_config(|config| {
|
||||
config.model_provider.name = "Non-OpenAI Model provider".to_string();
|
||||
config.compact_prompt = Some(SUMMARIZATION_PROMPT.to_string());
|
||||
});
|
||||
let initial = builder.build(&server).await?;
|
||||
let initial_thread = Arc::clone(&initial.codex);
|
||||
let rollout_path = initial
|
||||
.session_configured
|
||||
.rollout_path
|
||||
.clone()
|
||||
.expect("rollout path");
|
||||
|
||||
submit_user_turn(&initial_thread, "before compact").await?;
|
||||
submit_compact_turn(&initial_thread).await?;
|
||||
submit_user_turn(&initial_thread, "after compact").await?;
|
||||
shutdown_thread(&initial_thread).await?;
|
||||
|
||||
let resumed = builder
|
||||
.resume(&server, initial.home.clone(), rollout_path.clone())
|
||||
.await?;
|
||||
submit_user_turn(&resumed.codex, "after resume").await?;
|
||||
shutdown_thread(&resumed.codex).await?;
|
||||
|
||||
let forked = resumed
|
||||
.thread_manager
|
||||
.fork_thread(
|
||||
/*snapshot*/ 0usize,
|
||||
resumed.config.clone(),
|
||||
rollout_path,
|
||||
/*persist_extended_history*/ false,
|
||||
/*parent_trace*/ None,
|
||||
)
|
||||
.await?;
|
||||
submit_user_turn(&forked.thread, "after fork").await?;
|
||||
shutdown_thread(&forked.thread).await?;
|
||||
|
||||
let requests = request_log.requests();
|
||||
assert_eq!(requests.len(), 5, "expected five model requests");
|
||||
|
||||
let (initial_thread_id, first_generation) = window_id_parts(&requests[0]);
|
||||
let (compact_thread_id, compact_generation) = window_id_parts(&requests[1]);
|
||||
let (after_compact_thread_id, after_compact_generation) = window_id_parts(&requests[2]);
|
||||
let (after_resume_thread_id, after_resume_generation) = window_id_parts(&requests[3]);
|
||||
let (after_fork_thread_id, after_fork_generation) = window_id_parts(&requests[4]);
|
||||
|
||||
assert_eq!(first_generation, 0);
|
||||
assert_eq!(compact_thread_id, initial_thread_id);
|
||||
assert_eq!(compact_generation, 0);
|
||||
assert_eq!(after_compact_thread_id, initial_thread_id);
|
||||
assert_eq!(after_compact_generation, 1);
|
||||
assert_eq!(after_resume_thread_id, initial_thread_id);
|
||||
assert_eq!(after_resume_generation, 1);
|
||||
assert_ne!(after_fork_thread_id, initial_thread_id);
|
||||
assert_eq!(after_fork_generation, 0);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn submit_user_turn(codex: &Arc<CodexThread>, text: &str) -> Result<()> {
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: text.to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn submit_compact_turn(codex: &Arc<CodexThread>) -> Result<()> {
|
||||
codex.submit(Op::Compact).await?;
|
||||
let warning_event = wait_for_event(codex, |event| matches!(event, EventMsg::Warning(_))).await;
|
||||
let EventMsg::Warning(WarningEvent { message }) = warning_event else {
|
||||
panic!("expected warning event after compact");
|
||||
};
|
||||
assert_eq!(message, COMPACT_WARNING_MESSAGE);
|
||||
wait_for_event(codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn shutdown_thread(codex: &Arc<CodexThread>) -> Result<()> {
|
||||
codex.submit(Op::Shutdown).await?;
|
||||
wait_for_event(codex, |event| matches!(event, EventMsg::ShutdownComplete)).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn window_id_parts(request: &ResponsesRequest) -> (String, u64) {
|
||||
let window_id = request
|
||||
.header("x-codex-window-id")
|
||||
.expect("missing x-codex-window-id header");
|
||||
let (thread_id, generation) = window_id
|
||||
.rsplit_once(':')
|
||||
.unwrap_or_else(|| panic!("invalid window id header: {window_id}"));
|
||||
let generation = generation
|
||||
.parse::<u64>()
|
||||
.unwrap_or_else(|err| panic!("invalid window generation in {window_id}: {err}"));
|
||||
(thread_id.to_string(), generation)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ fn main() {
|
||||
println!("cargo:rerun-if-env-changed=PKG_CONFIG_ALLOW_CROSS");
|
||||
println!("cargo:rerun-if-env-changed=PKG_CONFIG_PATH");
|
||||
println!("cargo:rerun-if-env-changed=PKG_CONFIG_SYSROOT_DIR");
|
||||
println!("cargo:rerun-if-env-changed=CODEX_SKIP_VENDORED_BWRAP");
|
||||
|
||||
// Rebuild if the vendored bwrap sources change.
|
||||
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap_or_default());
|
||||
@@ -31,7 +32,7 @@ fn main() {
|
||||
);
|
||||
|
||||
let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
||||
if target_os != "linux" {
|
||||
if target_os != "linux" || env::var_os("CODEX_SKIP_VENDORED_BWRAP").is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,3 +26,6 @@ serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
tiny_http = { workspace = true }
|
||||
zeroize = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
|
||||
@@ -35,18 +35,20 @@ curl --fail --silent --show-error "${PROXY_BASE_URL}/shutdown"
|
||||
- Listens on the provided port or an ephemeral port if `--port` is not specified.
|
||||
- Accepts exactly `POST /v1/responses` (no query string). The request body is forwarded to `https://api.openai.com/v1/responses` with `Authorization: Bearer <key>` set. All original request headers (except any incoming `Authorization`) are forwarded upstream, with `Host` overridden to `api.openai.com`. For other requests, it responds with `403`.
|
||||
- Optionally writes a single-line JSON file with server info, currently `{ "port": <u16>, "pid": <u32> }`.
|
||||
- Optionally writes request/response JSON dumps to a directory. Each accepted request gets a pair of files that share a sequence/timestamp prefix, for example `000001-1846179912345-request.json` and `000001-1846179912345-response.json`. Header values are dumped in full except `Authorization` and any header whose name includes `cookie`, which are redacted. Bodies are written as parsed JSON when possible, otherwise as UTF-8 text.
|
||||
- Optional `--http-shutdown` enables `GET /shutdown` to terminate the process with exit code `0`. This allows one user (e.g., `root`) to start the proxy and another unprivileged user on the host to shut it down.
|
||||
|
||||
## CLI
|
||||
|
||||
```
|
||||
codex-responses-api-proxy [--port <PORT>] [--server-info <FILE>] [--http-shutdown] [--upstream-url <URL>]
|
||||
codex-responses-api-proxy [--port <PORT>] [--server-info <FILE>] [--http-shutdown] [--upstream-url <URL>] [--dump-dir <DIR>]
|
||||
```
|
||||
|
||||
- `--port <PORT>`: Port to bind on `127.0.0.1`. If omitted, an ephemeral port is chosen.
|
||||
- `--server-info <FILE>`: If set, the proxy writes a single line of JSON with `{ "port": <PORT>, "pid": <PID> }` once listening.
|
||||
- `--http-shutdown`: If set, enables `GET /shutdown` to exit the process with code `0`.
|
||||
- `--upstream-url <URL>`: Absolute URL to forward requests to. Defaults to `https://api.openai.com/v1/responses`.
|
||||
- `--dump-dir <DIR>`: If set, writes one request JSON file and one response JSON file per accepted proxy call under this directory. Filenames use a shared sequence/timestamp prefix so each pair is easy to correlate.
|
||||
- Authentication is fixed to `Authorization: Bearer <key>` to match the Codex CLI expectations.
|
||||
|
||||
For Azure, for example (ensure your deployment accepts `Authorization: Bearer <key>`):
|
||||
|
||||
360
codex-rs/responses-api-proxy/src/dump.rs
Normal file
360
codex-rs/responses-api-proxy/src/dump.rs
Normal file
@@ -0,0 +1,360 @@
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::io::Read;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::SystemTime;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use reqwest::header::HeaderMap;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use tiny_http::Header;
|
||||
use tiny_http::Method;
|
||||
|
||||
const AUTHORIZATION_HEADER_NAME: &str = "authorization";
|
||||
const REDACTED_HEADER_VALUE: &str = "[REDACTED]";
|
||||
|
||||
pub(crate) struct ExchangeDumper {
|
||||
dump_dir: PathBuf,
|
||||
next_sequence: AtomicU64,
|
||||
}
|
||||
|
||||
impl ExchangeDumper {
|
||||
pub(crate) fn new(dump_dir: PathBuf) -> io::Result<Self> {
|
||||
fs::create_dir_all(&dump_dir)?;
|
||||
|
||||
Ok(Self {
|
||||
dump_dir,
|
||||
next_sequence: AtomicU64::new(1),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn dump_request(
|
||||
&self,
|
||||
method: &Method,
|
||||
url: &str,
|
||||
headers: &[Header],
|
||||
body: &[u8],
|
||||
) -> io::Result<ExchangeDump> {
|
||||
let sequence = self.next_sequence.fetch_add(1, Ordering::Relaxed);
|
||||
let timestamp_ms = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |duration| duration.as_millis());
|
||||
let prefix = format!("{sequence:06}-{timestamp_ms}");
|
||||
|
||||
let request_path = self.dump_dir.join(format!("{prefix}-request.json"));
|
||||
let response_path = self.dump_dir.join(format!("{prefix}-response.json"));
|
||||
|
||||
let request_dump = RequestDump {
|
||||
method: method.as_str().to_string(),
|
||||
url: url.to_string(),
|
||||
headers: headers.iter().map(HeaderDump::from).collect(),
|
||||
body: dump_body(body),
|
||||
};
|
||||
|
||||
write_json_dump(&request_path, &request_dump)?;
|
||||
|
||||
Ok(ExchangeDump { response_path })
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct ExchangeDump {
|
||||
response_path: PathBuf,
|
||||
}
|
||||
|
||||
impl ExchangeDump {
|
||||
pub(crate) fn tee_response_body<R: Read>(
|
||||
self,
|
||||
status: u16,
|
||||
headers: &HeaderMap,
|
||||
response_body: R,
|
||||
) -> ResponseBodyDump<R> {
|
||||
ResponseBodyDump {
|
||||
response_body,
|
||||
response_path: self.response_path,
|
||||
status,
|
||||
headers: headers.iter().map(HeaderDump::from).collect(),
|
||||
body: Vec::new(),
|
||||
dump_written: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct ResponseBodyDump<R> {
|
||||
response_body: R,
|
||||
response_path: PathBuf,
|
||||
status: u16,
|
||||
headers: Vec<HeaderDump>,
|
||||
body: Vec<u8>,
|
||||
dump_written: bool,
|
||||
}
|
||||
|
||||
impl<R> ResponseBodyDump<R> {
|
||||
fn write_dump_if_needed(&mut self) {
|
||||
if self.dump_written {
|
||||
return;
|
||||
}
|
||||
|
||||
self.dump_written = true;
|
||||
|
||||
let response_dump = ResponseDump {
|
||||
status: self.status,
|
||||
headers: std::mem::take(&mut self.headers),
|
||||
body: dump_body(&self.body),
|
||||
};
|
||||
|
||||
if let Err(err) = write_json_dump(&self.response_path, &response_dump) {
|
||||
eprintln!(
|
||||
"responses-api-proxy failed to write {}: {err}",
|
||||
self.response_path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Read> Read for ResponseBodyDump<R> {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
let bytes_read = self.response_body.read(buf)?;
|
||||
if bytes_read == 0 {
|
||||
self.write_dump_if_needed();
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
self.body.extend_from_slice(&buf[..bytes_read]);
|
||||
Ok(bytes_read)
|
||||
}
|
||||
}
|
||||
|
||||
impl<R> Drop for ResponseBodyDump<R> {
|
||||
fn drop(&mut self) {
|
||||
self.write_dump_if_needed();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RequestDump {
|
||||
method: String,
|
||||
url: String,
|
||||
headers: Vec<HeaderDump>,
|
||||
body: Value,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ResponseDump {
|
||||
status: u16,
|
||||
headers: Vec<HeaderDump>,
|
||||
body: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct HeaderDump {
|
||||
name: String,
|
||||
value: String,
|
||||
}
|
||||
|
||||
impl From<&Header> for HeaderDump {
|
||||
fn from(header: &Header) -> Self {
|
||||
let name = header.field.as_str().to_string();
|
||||
let value = if should_redact_header(&name) {
|
||||
REDACTED_HEADER_VALUE.to_string()
|
||||
} else {
|
||||
header.value.as_str().to_string()
|
||||
};
|
||||
|
||||
Self { name, value }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<(&reqwest::header::HeaderName, &reqwest::header::HeaderValue)> for HeaderDump {
|
||||
fn from(header: (&reqwest::header::HeaderName, &reqwest::header::HeaderValue)) -> Self {
|
||||
let name = header.0.as_str();
|
||||
let value = if should_redact_header(name) {
|
||||
REDACTED_HEADER_VALUE.to_string()
|
||||
} else {
|
||||
String::from_utf8_lossy(header.1.as_bytes()).into_owned()
|
||||
};
|
||||
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
value,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn should_redact_header(name: &str) -> bool {
|
||||
name.eq_ignore_ascii_case(AUTHORIZATION_HEADER_NAME)
|
||||
|| name.to_ascii_lowercase().contains("cookie")
|
||||
}
|
||||
|
||||
fn dump_body(body: &[u8]) -> Value {
|
||||
serde_json::from_slice(body)
|
||||
.unwrap_or_else(|_| Value::String(String::from_utf8_lossy(body).into_owned()))
|
||||
}
|
||||
|
||||
fn write_json_dump(path: &PathBuf, dump: &impl Serialize) -> io::Result<()> {
|
||||
let mut bytes = serde_json::to_vec_pretty(dump)
|
||||
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
|
||||
bytes.push(b'\n');
|
||||
fs::write(path, bytes)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs;
|
||||
use std::io::Cursor;
|
||||
use std::io::Read;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
use reqwest::header::AUTHORIZATION;
|
||||
use reqwest::header::CONTENT_TYPE;
|
||||
use reqwest::header::HeaderMap;
|
||||
use reqwest::header::HeaderValue;
|
||||
use serde_json::json;
|
||||
use tiny_http::Header;
|
||||
use tiny_http::Method;
|
||||
|
||||
use super::ExchangeDumper;
|
||||
|
||||
static NEXT_TEST_DIR: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
#[test]
|
||||
fn dump_request_writes_redacted_headers_and_json_body() {
|
||||
let dump_dir = test_dump_dir();
|
||||
let dumper = ExchangeDumper::new(dump_dir.clone()).expect("create dumper");
|
||||
let headers = vec![
|
||||
Header::from_bytes(&b"Authorization"[..], &b"Bearer secret"[..])
|
||||
.expect("authorization header"),
|
||||
Header::from_bytes(&b"Cookie"[..], &b"user-session=secret"[..]).expect("cookie header"),
|
||||
Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..])
|
||||
.expect("content-type header"),
|
||||
];
|
||||
|
||||
let exchange_dump = dumper
|
||||
.dump_request(
|
||||
&Method::Post,
|
||||
"/v1/responses",
|
||||
&headers,
|
||||
br#"{"model":"gpt-5.4"}"#,
|
||||
)
|
||||
.expect("dump request");
|
||||
|
||||
let request_dump = fs::read_to_string(dump_file_with_suffix(&dump_dir, "-request.json"))
|
||||
.expect("read request dump");
|
||||
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&request_dump).expect("parse request dump"),
|
||||
json!({
|
||||
"method": "POST",
|
||||
"url": "/v1/responses",
|
||||
"headers": [
|
||||
{
|
||||
"name": "Authorization",
|
||||
"value": "[REDACTED]"
|
||||
},
|
||||
{
|
||||
"name": "Cookie",
|
||||
"value": "[REDACTED]"
|
||||
},
|
||||
{
|
||||
"name": "Content-Type",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"model": "gpt-5.4"
|
||||
}
|
||||
})
|
||||
);
|
||||
assert!(
|
||||
exchange_dump
|
||||
.response_path
|
||||
.file_name()
|
||||
.expect("response dump file name")
|
||||
.to_string_lossy()
|
||||
.ends_with("-response.json")
|
||||
);
|
||||
|
||||
fs::remove_dir_all(dump_dir).expect("remove test dump dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_body_dump_streams_body_and_writes_response_file() {
|
||||
let dump_dir = test_dump_dir();
|
||||
let dumper = ExchangeDumper::new(dump_dir.clone()).expect("create dumper");
|
||||
let exchange_dump = dumper
|
||||
.dump_request(&Method::Post, "/v1/responses", &[], b"{}")
|
||||
.expect("dump request");
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static("text/event-stream"));
|
||||
headers.insert(AUTHORIZATION, HeaderValue::from_static("Bearer secret"));
|
||||
headers.insert(
|
||||
"set-cookie",
|
||||
HeaderValue::from_static("user-session=secret"),
|
||||
);
|
||||
|
||||
let mut response_body = String::new();
|
||||
exchange_dump
|
||||
.tee_response_body(
|
||||
/*status*/ 200,
|
||||
&headers,
|
||||
Cursor::new(b"data: hello\n\n".to_vec()),
|
||||
)
|
||||
.read_to_string(&mut response_body)
|
||||
.expect("read response body");
|
||||
|
||||
let response_dump = fs::read_to_string(dump_file_with_suffix(&dump_dir, "-response.json"))
|
||||
.expect("read response dump");
|
||||
|
||||
assert_eq!(response_body, "data: hello\n\n");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&response_dump).expect("parse response dump"),
|
||||
json!({
|
||||
"status": 200,
|
||||
"headers": [
|
||||
{
|
||||
"name": "content-type",
|
||||
"value": "text/event-stream"
|
||||
},
|
||||
{
|
||||
"name": "authorization",
|
||||
"value": "[REDACTED]"
|
||||
},
|
||||
{
|
||||
"name": "set-cookie",
|
||||
"value": "[REDACTED]"
|
||||
}
|
||||
],
|
||||
"body": "data: hello\n\n"
|
||||
})
|
||||
);
|
||||
|
||||
fs::remove_dir_all(dump_dir).expect("remove test dump dir");
|
||||
}
|
||||
|
||||
fn test_dump_dir() -> std::path::PathBuf {
|
||||
let test_id = NEXT_TEST_DIR.fetch_add(1, Ordering::Relaxed);
|
||||
let dump_dir = std::env::temp_dir().join(format!(
|
||||
"codex-responses-api-proxy-dump-test-{}-{test_id}",
|
||||
std::process::id()
|
||||
));
|
||||
fs::create_dir_all(&dump_dir).expect("create test dump dir");
|
||||
dump_dir
|
||||
}
|
||||
|
||||
fn dump_file_with_suffix(dump_dir: &std::path::Path, suffix: &str) -> std::path::PathBuf {
|
||||
let mut matches = fs::read_dir(dump_dir)
|
||||
.expect("read dump dir")
|
||||
.map(|entry| entry.expect("read dump entry").path())
|
||||
.filter(|path| path.to_string_lossy().ends_with(suffix))
|
||||
.collect::<Vec<_>>();
|
||||
matches.sort();
|
||||
|
||||
assert_eq!(matches.len(), 1);
|
||||
matches.pop().expect("single dump file")
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::fs::File;
|
||||
use std::fs::{self};
|
||||
use std::io::Read;
|
||||
use std::io::Write;
|
||||
use std::net::SocketAddr;
|
||||
use std::net::TcpListener;
|
||||
@@ -27,7 +28,9 @@ use tiny_http::Response;
|
||||
use tiny_http::Server;
|
||||
use tiny_http::StatusCode;
|
||||
|
||||
mod dump;
|
||||
mod read_api_key;
|
||||
use dump::ExchangeDumper;
|
||||
use read_api_key::read_auth_header_from_stdin;
|
||||
|
||||
/// CLI arguments for the proxy.
|
||||
@@ -49,6 +52,10 @@ pub struct Args {
|
||||
/// Absolute URL the proxy should forward requests to (defaults to OpenAI).
|
||||
#[arg(long, default_value = "https://api.openai.com/v1/responses")]
|
||||
pub upstream_url: String,
|
||||
|
||||
/// Directory where request/response dumps should be written as JSON.
|
||||
#[arg(long, value_name = "DIR")]
|
||||
pub dump_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -79,6 +86,12 @@ pub fn run_main(args: Args) -> Result<()> {
|
||||
upstream_url,
|
||||
host_header,
|
||||
});
|
||||
let dump_dir = args
|
||||
.dump_dir
|
||||
.map(ExchangeDumper::new)
|
||||
.transpose()
|
||||
.context("creating --dump-dir")?
|
||||
.map(Arc::new);
|
||||
|
||||
let (listener, bound_addr) = bind_listener(args.port)?;
|
||||
if let Some(path) = args.server_info.as_ref() {
|
||||
@@ -100,13 +113,20 @@ pub fn run_main(args: Args) -> Result<()> {
|
||||
for request in server.incoming_requests() {
|
||||
let client = client.clone();
|
||||
let forward_config = forward_config.clone();
|
||||
let dump_dir = dump_dir.clone();
|
||||
std::thread::spawn(move || {
|
||||
if http_shutdown && request.method() == &Method::Get && request.url() == "/shutdown" {
|
||||
let _ = request.respond(Response::new_empty(StatusCode(200)));
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
if let Err(e) = forward_request(&client, auth_header, &forward_config, request) {
|
||||
if let Err(e) = forward_request(
|
||||
&client,
|
||||
auth_header,
|
||||
&forward_config,
|
||||
dump_dir.as_deref(),
|
||||
request,
|
||||
) {
|
||||
eprintln!("forwarding error: {e}");
|
||||
}
|
||||
});
|
||||
@@ -144,6 +164,7 @@ fn forward_request(
|
||||
client: &Client,
|
||||
auth_header: &'static str,
|
||||
config: &ForwardConfig,
|
||||
dump_dir: Option<&ExchangeDumper>,
|
||||
mut req: Request,
|
||||
) -> Result<()> {
|
||||
// Only allow POST /v1/responses exactly, no query string.
|
||||
@@ -159,8 +180,18 @@ fn forward_request(
|
||||
|
||||
// Read request body
|
||||
let mut body = Vec::new();
|
||||
let mut reader = req.as_reader();
|
||||
std::io::Read::read_to_end(&mut reader, &mut body)?;
|
||||
let reader = req.as_reader();
|
||||
reader.read_to_end(&mut body)?;
|
||||
|
||||
let exchange_dump = dump_dir.and_then(|dump_dir| {
|
||||
dump_dir
|
||||
.dump_request(&method, &url_path, req.headers(), &body)
|
||||
.map_err(|err| {
|
||||
eprintln!("responses-api-proxy failed to dump request: {err}");
|
||||
err
|
||||
})
|
||||
.ok()
|
||||
});
|
||||
|
||||
// Build headers for upstream, forwarding everything from the incoming
|
||||
// request except Authorization (we replace it below).
|
||||
@@ -224,10 +255,17 @@ fn forward_request(
|
||||
}
|
||||
});
|
||||
|
||||
let response_body: Box<dyn Read + Send> = if let Some(exchange_dump) = exchange_dump {
|
||||
let headers = upstream_resp.headers().clone();
|
||||
Box::new(exchange_dump.tee_response_body(status.as_u16(), &headers, upstream_resp))
|
||||
} else {
|
||||
Box::new(upstream_resp)
|
||||
};
|
||||
|
||||
let response = Response::new(
|
||||
StatusCode(status.as_u16()),
|
||||
response_headers,
|
||||
upstream_resp,
|
||||
response_body,
|
||||
content_length,
|
||||
None,
|
||||
);
|
||||
|
||||
@@ -1,48 +1,60 @@
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde::Serializer;
|
||||
use serde_json::Value as JsonValue;
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Generic JSON-Schema subset needed for our tool definitions.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "lowercase")]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum JsonSchema {
|
||||
Boolean {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
description: Option<String>,
|
||||
},
|
||||
String {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
description: Option<String>,
|
||||
},
|
||||
/// MCP schema allows "number" | "integer" for Number.
|
||||
#[serde(alias = "integer")]
|
||||
Number {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
description: Option<String>,
|
||||
},
|
||||
Null {
|
||||
description: Option<String>,
|
||||
},
|
||||
Array {
|
||||
items: Box<JsonSchema>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
description: Option<String>,
|
||||
},
|
||||
Object {
|
||||
properties: BTreeMap<String, JsonSchema>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
required: Option<Vec<String>>,
|
||||
#[serde(
|
||||
rename = "additionalProperties",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
additional_properties: Option<AdditionalProperties>,
|
||||
},
|
||||
Const {
|
||||
value: JsonValue,
|
||||
schema_type: Option<String>,
|
||||
description: Option<String>,
|
||||
},
|
||||
Enum {
|
||||
values: Vec<JsonValue>,
|
||||
schema_type: Option<String>,
|
||||
description: Option<String>,
|
||||
},
|
||||
AnyOf {
|
||||
variants: Vec<JsonSchema>,
|
||||
description: Option<String>,
|
||||
},
|
||||
OneOf {
|
||||
variants: Vec<JsonSchema>,
|
||||
description: Option<String>,
|
||||
},
|
||||
AllOf {
|
||||
variants: Vec<JsonSchema>,
|
||||
description: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Whether additional properties are allowed, and if so, any required schema.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(untagged)]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum AdditionalProperties {
|
||||
Boolean(bool),
|
||||
Schema(Box<JsonSchema>),
|
||||
@@ -60,18 +72,41 @@ impl From<JsonSchema> for AdditionalProperties {
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for JsonSchema {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
json_schema_to_json(self).serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for AdditionalProperties {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
match self {
|
||||
Self::Boolean(value) => value.serialize(serializer),
|
||||
Self::Schema(schema) => json_schema_to_json(schema).serialize(serializer),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the tool `input_schema` or return an error for invalid schema.
|
||||
pub fn parse_tool_input_schema(input_schema: &JsonValue) -> Result<JsonSchema, serde_json::Error> {
|
||||
let mut input_schema = input_schema.clone();
|
||||
sanitize_json_schema(&mut input_schema);
|
||||
serde_json::from_value::<JsonSchema>(input_schema)
|
||||
parse_json_schema(&input_schema)
|
||||
}
|
||||
|
||||
/// Sanitize a JSON Schema (as serde_json::Value) so it can fit our limited
|
||||
/// JsonSchema enum. This function:
|
||||
/// - Ensures every schema object has a "type". If missing, infers it from
|
||||
/// common keywords (properties => object, items => array, enum/const/format => string)
|
||||
/// and otherwise defaults to "string".
|
||||
/// - Infers a concrete `"type"` when it is missing and the shape can be reduced
|
||||
/// to our supported subset (properties => object, items => array,
|
||||
/// enum/const/format => string).
|
||||
/// - Preserves explicit combiners like `anyOf`/`oneOf`/`allOf` and nullable
|
||||
/// unions instead of collapsing them to a single fallback type.
|
||||
/// - Fills required child fields (e.g. array items, object properties) with
|
||||
/// permissive defaults when absent.
|
||||
fn sanitize_json_schema(value: &mut JsonValue) {
|
||||
@@ -107,22 +142,6 @@ fn sanitize_json_schema(value: &mut JsonValue) {
|
||||
.and_then(|value| value.as_str())
|
||||
.map(str::to_string);
|
||||
|
||||
if schema_type.is_none()
|
||||
&& let Some(JsonValue::Array(types)) = map.get("type")
|
||||
{
|
||||
for candidate in types {
|
||||
if let Some(candidate_type) = candidate.as_str()
|
||||
&& matches!(
|
||||
candidate_type,
|
||||
"object" | "array" | "string" | "number" | "integer" | "boolean"
|
||||
)
|
||||
{
|
||||
schema_type = Some(candidate_type.to_string());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if schema_type.is_none() {
|
||||
if map.contains_key("properties")
|
||||
|| map.contains_key("required")
|
||||
@@ -146,10 +165,11 @@ fn sanitize_json_schema(value: &mut JsonValue) {
|
||||
}
|
||||
}
|
||||
|
||||
let schema_type = schema_type.unwrap_or_else(|| "string".to_string());
|
||||
map.insert("type".to_string(), JsonValue::String(schema_type.clone()));
|
||||
if let Some(schema_type) = &schema_type {
|
||||
map.insert("type".to_string(), JsonValue::String(schema_type.clone()));
|
||||
}
|
||||
|
||||
if schema_type == "object" {
|
||||
if schema_type.as_deref() == Some("object") {
|
||||
if !map.contains_key("properties") {
|
||||
map.insert(
|
||||
"properties".to_string(),
|
||||
@@ -163,7 +183,7 @@ fn sanitize_json_schema(value: &mut JsonValue) {
|
||||
}
|
||||
}
|
||||
|
||||
if schema_type == "array" && !map.contains_key("items") {
|
||||
if schema_type.as_deref() == Some("array") && !map.contains_key("items") {
|
||||
map.insert("items".to_string(), json!({ "type": "string" }));
|
||||
}
|
||||
}
|
||||
@@ -171,6 +191,284 @@ fn sanitize_json_schema(value: &mut JsonValue) {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_json_schema(value: &JsonValue) -> Result<JsonSchema, serde_json::Error> {
|
||||
match value {
|
||||
JsonValue::Bool(_) => Ok(JsonSchema::String { description: None }),
|
||||
JsonValue::Object(map) => {
|
||||
let description = map
|
||||
.get("description")
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(str::to_string);
|
||||
|
||||
if let Some(value) = map.get("const") {
|
||||
return Ok(JsonSchema::Const {
|
||||
value: value.clone(),
|
||||
schema_type: map
|
||||
.get("type")
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(str::to_string),
|
||||
description,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(values) = map.get("enum").and_then(JsonValue::as_array) {
|
||||
return Ok(JsonSchema::Enum {
|
||||
values: values.clone(),
|
||||
schema_type: map
|
||||
.get("type")
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(str::to_string),
|
||||
description,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(variants) = map.get("anyOf").and_then(JsonValue::as_array) {
|
||||
return Ok(JsonSchema::AnyOf {
|
||||
variants: variants
|
||||
.iter()
|
||||
.map(parse_json_schema)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
description,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(variants) = map.get("oneOf").and_then(JsonValue::as_array) {
|
||||
return Ok(JsonSchema::OneOf {
|
||||
variants: variants
|
||||
.iter()
|
||||
.map(parse_json_schema)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
description,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(variants) = map.get("allOf").and_then(JsonValue::as_array) {
|
||||
return Ok(JsonSchema::AllOf {
|
||||
variants: variants
|
||||
.iter()
|
||||
.map(parse_json_schema)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
description,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(types) = map.get("type").and_then(JsonValue::as_array) {
|
||||
return Ok(JsonSchema::AnyOf {
|
||||
variants: types
|
||||
.iter()
|
||||
.filter_map(JsonValue::as_str)
|
||||
.map(|schema_type| {
|
||||
parse_json_schema(&json!({
|
||||
"type": schema_type,
|
||||
}))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
description,
|
||||
});
|
||||
}
|
||||
|
||||
match map
|
||||
.get("type")
|
||||
.and_then(JsonValue::as_str)
|
||||
.unwrap_or("string")
|
||||
{
|
||||
"boolean" => Ok(JsonSchema::Boolean { description }),
|
||||
"string" => Ok(JsonSchema::String { description }),
|
||||
"number" | "integer" => Ok(JsonSchema::Number { description }),
|
||||
"null" => Ok(JsonSchema::Null { description }),
|
||||
"array" => Ok(JsonSchema::Array {
|
||||
items: Box::new(parse_json_schema(
|
||||
map.get("items").unwrap_or(&json!({ "type": "string" })),
|
||||
)?),
|
||||
description,
|
||||
}),
|
||||
"object" => {
|
||||
let properties = map
|
||||
.get("properties")
|
||||
.and_then(JsonValue::as_object)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|(name, value)| Ok((name, parse_json_schema(&value)?)))
|
||||
.collect::<Result<BTreeMap<_, _>, serde_json::Error>>()?;
|
||||
let required = map
|
||||
.get("required")
|
||||
.and_then(JsonValue::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(JsonValue::as_str)
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
let additional_properties = map
|
||||
.get("additionalProperties")
|
||||
.map(parse_additional_properties)
|
||||
.transpose()?;
|
||||
Ok(JsonSchema::Object {
|
||||
properties,
|
||||
required,
|
||||
additional_properties,
|
||||
})
|
||||
}
|
||||
_ => Ok(JsonSchema::String { description }),
|
||||
}
|
||||
}
|
||||
_ => Ok(JsonSchema::String { description: None }),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_additional_properties(
|
||||
value: &JsonValue,
|
||||
) -> Result<AdditionalProperties, serde_json::Error> {
|
||||
match value {
|
||||
JsonValue::Bool(flag) => Ok(AdditionalProperties::Boolean(*flag)),
|
||||
_ => Ok(AdditionalProperties::Schema(Box::new(parse_json_schema(
|
||||
value,
|
||||
)?))),
|
||||
}
|
||||
}
|
||||
|
||||
fn json_schema_to_json(schema: &JsonSchema) -> JsonValue {
|
||||
match schema {
|
||||
JsonSchema::Boolean { description } => {
|
||||
let mut map = serde_json::Map::new();
|
||||
map.insert("type".to_string(), JsonValue::String("boolean".to_string()));
|
||||
insert_description(&mut map, description.as_deref());
|
||||
JsonValue::Object(map)
|
||||
}
|
||||
JsonSchema::String { description } => {
|
||||
let mut map = serde_json::Map::new();
|
||||
map.insert("type".to_string(), JsonValue::String("string".to_string()));
|
||||
insert_description(&mut map, description.as_deref());
|
||||
JsonValue::Object(map)
|
||||
}
|
||||
JsonSchema::Number { description } => {
|
||||
let mut map = serde_json::Map::new();
|
||||
map.insert("type".to_string(), JsonValue::String("number".to_string()));
|
||||
insert_description(&mut map, description.as_deref());
|
||||
JsonValue::Object(map)
|
||||
}
|
||||
JsonSchema::Null { description } => {
|
||||
let mut map = serde_json::Map::new();
|
||||
map.insert("type".to_string(), JsonValue::String("null".to_string()));
|
||||
insert_description(&mut map, description.as_deref());
|
||||
JsonValue::Object(map)
|
||||
}
|
||||
JsonSchema::Array { items, description } => {
|
||||
let mut map = serde_json::Map::new();
|
||||
map.insert("type".to_string(), JsonValue::String("array".to_string()));
|
||||
map.insert("items".to_string(), json_schema_to_json(items));
|
||||
insert_description(&mut map, description.as_deref());
|
||||
JsonValue::Object(map)
|
||||
}
|
||||
JsonSchema::Object {
|
||||
properties,
|
||||
required,
|
||||
additional_properties,
|
||||
} => {
|
||||
let mut map = serde_json::Map::new();
|
||||
map.insert("type".to_string(), JsonValue::String("object".to_string()));
|
||||
map.insert(
|
||||
"properties".to_string(),
|
||||
JsonValue::Object(
|
||||
properties
|
||||
.iter()
|
||||
.map(|(name, value)| (name.clone(), json_schema_to_json(value)))
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
if let Some(required) = required {
|
||||
map.insert(
|
||||
"required".to_string(),
|
||||
JsonValue::Array(required.iter().cloned().map(JsonValue::String).collect()),
|
||||
);
|
||||
}
|
||||
if let Some(additional_properties) = additional_properties {
|
||||
map.insert(
|
||||
"additionalProperties".to_string(),
|
||||
match additional_properties {
|
||||
AdditionalProperties::Boolean(flag) => JsonValue::Bool(*flag),
|
||||
AdditionalProperties::Schema(schema) => json_schema_to_json(schema),
|
||||
},
|
||||
);
|
||||
}
|
||||
JsonValue::Object(map)
|
||||
}
|
||||
JsonSchema::Const {
|
||||
value,
|
||||
schema_type,
|
||||
description,
|
||||
} => {
|
||||
let mut map = serde_json::Map::new();
|
||||
map.insert("const".to_string(), value.clone());
|
||||
if let Some(schema_type) = schema_type {
|
||||
map.insert("type".to_string(), JsonValue::String(schema_type.clone()));
|
||||
}
|
||||
insert_description(&mut map, description.as_deref());
|
||||
JsonValue::Object(map)
|
||||
}
|
||||
JsonSchema::Enum {
|
||||
values,
|
||||
schema_type,
|
||||
description,
|
||||
} => {
|
||||
let mut map = serde_json::Map::new();
|
||||
map.insert("enum".to_string(), JsonValue::Array(values.clone()));
|
||||
if let Some(schema_type) = schema_type {
|
||||
map.insert("type".to_string(), JsonValue::String(schema_type.clone()));
|
||||
}
|
||||
insert_description(&mut map, description.as_deref());
|
||||
JsonValue::Object(map)
|
||||
}
|
||||
JsonSchema::AnyOf {
|
||||
variants,
|
||||
description,
|
||||
} => {
|
||||
let mut map = serde_json::Map::new();
|
||||
map.insert(
|
||||
"anyOf".to_string(),
|
||||
JsonValue::Array(variants.iter().map(json_schema_to_json).collect()),
|
||||
);
|
||||
insert_description(&mut map, description.as_deref());
|
||||
JsonValue::Object(map)
|
||||
}
|
||||
JsonSchema::OneOf {
|
||||
variants,
|
||||
description,
|
||||
} => {
|
||||
let mut map = serde_json::Map::new();
|
||||
map.insert(
|
||||
"oneOf".to_string(),
|
||||
JsonValue::Array(variants.iter().map(json_schema_to_json).collect()),
|
||||
);
|
||||
insert_description(&mut map, description.as_deref());
|
||||
JsonValue::Object(map)
|
||||
}
|
||||
JsonSchema::AllOf {
|
||||
variants,
|
||||
description,
|
||||
} => {
|
||||
let mut map = serde_json::Map::new();
|
||||
map.insert(
|
||||
"allOf".to_string(),
|
||||
JsonValue::Array(variants.iter().map(json_schema_to_json).collect()),
|
||||
);
|
||||
insert_description(&mut map, description.as_deref());
|
||||
JsonValue::Object(map)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_description(map: &mut serde_json::Map<String, JsonValue>, description: Option<&str>) {
|
||||
if let Some(description) = description {
|
||||
map.insert(
|
||||
"description".to_string(),
|
||||
JsonValue::String(description.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "json_schema_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -87,7 +87,13 @@ fn parse_tool_input_schema_sanitizes_additional_properties_schema() {
|
||||
JsonSchema::Object {
|
||||
properties: BTreeMap::from([(
|
||||
"value".to_string(),
|
||||
JsonSchema::String { description: None },
|
||||
JsonSchema::AnyOf {
|
||||
variants: vec![
|
||||
JsonSchema::String { description: None },
|
||||
JsonSchema::Number { description: None },
|
||||
],
|
||||
description: None,
|
||||
},
|
||||
)]),
|
||||
required: Some(vec!["value".to_string()]),
|
||||
additional_properties: None,
|
||||
@@ -96,3 +102,157 @@ fn parse_tool_input_schema_sanitizes_additional_properties_schema() {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tool_input_schema_preserves_web_run_shape() {
|
||||
let schema = parse_tool_input_schema(&serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"open": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ref_id": {"type": "string"},
|
||||
"lineno": {"anyOf": [{"type": "integer"}, {"type": "null"}]}
|
||||
},
|
||||
"required": ["ref_id"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{"type": "null"}
|
||||
]
|
||||
},
|
||||
"tagged_list": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {"type": "const", "const": "tagged"},
|
||||
"variant": {"type": "enum", "enum": ["alpha", "beta"]},
|
||||
"scope": {"type": "enum", "enum": ["one", "two"]}
|
||||
},
|
||||
"required": ["kind", "variant", "scope"]
|
||||
}
|
||||
},
|
||||
{"type": "null"}
|
||||
]
|
||||
},
|
||||
"response_length": {
|
||||
"type": "enum",
|
||||
"enum": ["short", "medium", "long"]
|
||||
}
|
||||
}
|
||||
}))
|
||||
.expect("parse schema");
|
||||
|
||||
assert_eq!(
|
||||
schema,
|
||||
JsonSchema::Object {
|
||||
properties: BTreeMap::from([
|
||||
(
|
||||
"open".to_string(),
|
||||
JsonSchema::AnyOf {
|
||||
variants: vec![
|
||||
JsonSchema::Array {
|
||||
items: Box::new(JsonSchema::Object {
|
||||
properties: BTreeMap::from([
|
||||
(
|
||||
"lineno".to_string(),
|
||||
JsonSchema::AnyOf {
|
||||
variants: vec![
|
||||
JsonSchema::Number { description: None },
|
||||
JsonSchema::Null { description: None },
|
||||
],
|
||||
description: None,
|
||||
},
|
||||
),
|
||||
(
|
||||
"ref_id".to_string(),
|
||||
JsonSchema::String { description: None },
|
||||
),
|
||||
]),
|
||||
required: Some(vec!["ref_id".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
}),
|
||||
description: None,
|
||||
},
|
||||
JsonSchema::Null { description: None },
|
||||
],
|
||||
description: None,
|
||||
},
|
||||
),
|
||||
(
|
||||
"response_length".to_string(),
|
||||
JsonSchema::Enum {
|
||||
values: vec![
|
||||
serde_json::json!("short"),
|
||||
serde_json::json!("medium"),
|
||||
serde_json::json!("long"),
|
||||
],
|
||||
schema_type: Some("enum".to_string()),
|
||||
description: None,
|
||||
},
|
||||
),
|
||||
(
|
||||
"tagged_list".to_string(),
|
||||
JsonSchema::AnyOf {
|
||||
variants: vec![
|
||||
JsonSchema::Array {
|
||||
items: Box::new(JsonSchema::Object {
|
||||
properties: BTreeMap::from([
|
||||
(
|
||||
"kind".to_string(),
|
||||
JsonSchema::Const {
|
||||
value: serde_json::json!("tagged"),
|
||||
schema_type: Some("const".to_string()),
|
||||
description: None,
|
||||
},
|
||||
),
|
||||
(
|
||||
"scope".to_string(),
|
||||
JsonSchema::Enum {
|
||||
values: vec![
|
||||
serde_json::json!("one"),
|
||||
serde_json::json!("two"),
|
||||
],
|
||||
schema_type: Some("enum".to_string()),
|
||||
description: None,
|
||||
},
|
||||
),
|
||||
(
|
||||
"variant".to_string(),
|
||||
JsonSchema::Enum {
|
||||
values: vec![
|
||||
serde_json::json!("alpha"),
|
||||
serde_json::json!("beta"),
|
||||
],
|
||||
schema_type: Some("enum".to_string()),
|
||||
description: None,
|
||||
},
|
||||
),
|
||||
]),
|
||||
required: Some(vec![
|
||||
"kind".to_string(),
|
||||
"variant".to_string(),
|
||||
"scope".to_string(),
|
||||
]),
|
||||
additional_properties: None,
|
||||
}),
|
||||
description: None,
|
||||
},
|
||||
JsonSchema::Null { description: None },
|
||||
],
|
||||
description: None,
|
||||
},
|
||||
),
|
||||
]),
|
||||
required: None,
|
||||
additional_properties: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1543,6 +1543,93 @@ fn code_mode_augments_mcp_tool_descriptions_with_namespaced_sample() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_mode_preserves_nullable_and_literal_mcp_input_shapes() {
|
||||
let model_info = model_info();
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::CodeMode);
|
||||
features.enable(Feature::UnifiedExec);
|
||||
let available_models = Vec::new();
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
available_models: &available_models,
|
||||
features: &features,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
session_source: SessionSource::Cli,
|
||||
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
});
|
||||
|
||||
let (tools, _) = build_specs(
|
||||
&tools_config,
|
||||
Some(HashMap::from([(
|
||||
"mcp__sample__fn".to_string(),
|
||||
mcp_tool(
|
||||
"fn",
|
||||
"Sample fn",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"open": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ref_id": {"type": "string"},
|
||||
"lineno": {"anyOf": [{"type": "integer"}, {"type": "null"}]}
|
||||
},
|
||||
"required": ["ref_id"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{"type": "null"}
|
||||
]
|
||||
},
|
||||
"tagged_list": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {"type": "const", "const": "tagged"},
|
||||
"variant": {"type": "enum", "enum": ["alpha", "beta"]},
|
||||
"scope": {"type": "enum", "enum": ["one", "two"]}
|
||||
},
|
||||
"required": ["kind", "variant", "scope"]
|
||||
}
|
||||
},
|
||||
{"type": "null"}
|
||||
]
|
||||
},
|
||||
"response_length": {"type": "enum", "enum": ["short", "medium", "long"]}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}),
|
||||
),
|
||||
)])),
|
||||
/*app_tools*/ None,
|
||||
&[],
|
||||
);
|
||||
|
||||
let ToolSpec::Function(ResponsesApiTool { description, .. }) =
|
||||
&find_tool(&tools, "mcp__sample__fn").spec
|
||||
else {
|
||||
panic!("expected function tool");
|
||||
};
|
||||
|
||||
assert!(description.contains("mcp__sample__fn(args: { open?: Array<{"));
|
||||
assert!(description.contains("lineno?: number | null;"));
|
||||
assert!(description.contains("ref_id: string;"));
|
||||
assert!(description.contains("response_length?: \"short\" | \"medium\" | \"long\";"));
|
||||
assert!(description.contains("tagged_list?: Array<{"));
|
||||
assert!(description.contains("kind: \"tagged\";"));
|
||||
assert!(description.contains("variant: \"alpha\" | \"beta\";"));
|
||||
assert!(!description.contains("open?: string;"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_mode_augments_builtin_tool_descriptions_with_typed_sample() {
|
||||
let model_info = model_info();
|
||||
@@ -1574,7 +1661,7 @@ fn code_mode_augments_builtin_tool_descriptions_with_typed_sample() {
|
||||
|
||||
assert_eq!(
|
||||
description,
|
||||
"View a local image from the filesystem (only use if given a full filepath by the user, and the image isn't already attached to the thread context within <image ...> tags).\n\nexec tool declaration:\n```ts\ndeclare const tools: { view_image(args: { path: string; }): Promise<{ detail: string | null; image_url: string; }>; };\n```"
|
||||
"View a local image from the filesystem (only use if given a full filepath by the user, and the image isn't already attached to the thread context within <image ...> tags).\n\nexec tool declaration:\n```ts\ndeclare const tools: { view_image(args: {\n // Local filesystem path to an image file\n path: string;\n}): Promise<{ detail: string | null; image_url: string; }>; };\n```"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1842,7 +1929,37 @@ fn strip_descriptions_schema(schema: &mut JsonSchema) {
|
||||
match schema {
|
||||
JsonSchema::Boolean { description }
|
||||
| JsonSchema::String { description }
|
||||
| JsonSchema::Number { description } => {
|
||||
| JsonSchema::Number { description }
|
||||
| JsonSchema::Null { description } => {
|
||||
*description = None;
|
||||
}
|
||||
JsonSchema::Const {
|
||||
description,
|
||||
value: _,
|
||||
schema_type: _,
|
||||
}
|
||||
| JsonSchema::Enum {
|
||||
description,
|
||||
values: _,
|
||||
schema_type: _,
|
||||
} => {
|
||||
*description = None;
|
||||
}
|
||||
JsonSchema::AnyOf {
|
||||
variants,
|
||||
description,
|
||||
}
|
||||
| JsonSchema::OneOf {
|
||||
variants,
|
||||
description,
|
||||
}
|
||||
| JsonSchema::AllOf {
|
||||
variants,
|
||||
description,
|
||||
} => {
|
||||
for variant in variants {
|
||||
strip_descriptions_schema(variant);
|
||||
}
|
||||
*description = None;
|
||||
}
|
||||
JsonSchema::Array { items, description } => {
|
||||
|
||||
@@ -301,7 +301,7 @@ mod tests {
|
||||
};
|
||||
let temp_dir = tempdir().expect("base dir");
|
||||
let abs_path_buf = {
|
||||
let guard = AbsolutePathBufGuard::new(temp_dir.path());
|
||||
let _guard = AbsolutePathBufGuard::new(temp_dir.path());
|
||||
let input =
|
||||
serde_json::to_string(r#"~\code"#).expect("string should serialize as JSON");
|
||||
serde_json::from_str::<AbsolutePathBuf>(&input).expect("is valid abs path")
|
||||
|
||||
Reference in New Issue
Block a user