mirror of
https://github.com/openai/codex.git
synced 2026-11-08 20:40:33 +00:00
Merge branch 'main' into fix/mcp-image-output-nonfirst
This commit is contained in:
@@ -32,30 +32,132 @@ case "${TARGET}" in
|
||||
;;
|
||||
esac
|
||||
|
||||
if command -v clang++ >/dev/null; then
|
||||
cxx="$(command -v clang++)"
|
||||
echo "CXXFLAGS=--target=${TARGET} -stdlib=libc++ -pthread" >> "$GITHUB_ENV"
|
||||
echo "CFLAGS=--target=${TARGET} -pthread" >> "$GITHUB_ENV"
|
||||
if command -v clang >/dev/null; then
|
||||
cc="$(command -v clang)"
|
||||
echo "CC=${cc}" >> "$GITHUB_ENV"
|
||||
echo "TARGET_CC=${cc}" >> "$GITHUB_ENV"
|
||||
target_cc_var="CC_${TARGET}"
|
||||
target_cc_var="${target_cc_var//-/_}"
|
||||
echo "${target_cc_var}=${cc}" >> "$GITHUB_ENV"
|
||||
fi
|
||||
elif command -v "${arch}-linux-musl-g++" >/dev/null; then
|
||||
cxx="$(command -v "${arch}-linux-musl-g++")"
|
||||
elif command -v musl-g++ >/dev/null; then
|
||||
cxx="$(command -v musl-g++)"
|
||||
# Use the musl toolchain as the Rust linker to avoid Zig injecting its own CRT.
|
||||
if command -v "${arch}-linux-musl-gcc" >/dev/null; then
|
||||
musl_linker="$(command -v "${arch}-linux-musl-gcc")"
|
||||
elif command -v musl-gcc >/dev/null; then
|
||||
cxx="$(command -v musl-gcc)"
|
||||
echo "CFLAGS=-pthread" >> "$GITHUB_ENV"
|
||||
musl_linker="$(command -v musl-gcc)"
|
||||
else
|
||||
echo "musl g++ not found after install; arch=${arch}" >&2
|
||||
echo "musl gcc not found after install; arch=${arch}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
zig_target="${TARGET/-unknown-linux-musl/-linux-musl}"
|
||||
runner_temp="${RUNNER_TEMP:-/tmp}"
|
||||
tool_root="${runner_temp}/codex-musl-tools-${TARGET}"
|
||||
mkdir -p "${tool_root}"
|
||||
|
||||
sysroot=""
|
||||
if command -v zig >/dev/null; then
|
||||
zig_bin="$(command -v zig)"
|
||||
cc="${tool_root}/zigcc"
|
||||
cxx="${tool_root}/zigcxx"
|
||||
|
||||
cat >"${cc}" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
args=()
|
||||
skip_next=0
|
||||
for arg in "\$@"; do
|
||||
if [[ "\${skip_next}" -eq 1 ]]; then
|
||||
skip_next=0
|
||||
continue
|
||||
fi
|
||||
case "\${arg}" in
|
||||
--target)
|
||||
skip_next=1
|
||||
continue
|
||||
;;
|
||||
--target=*|-target=*|-target)
|
||||
# Drop any explicit --target/-target flags. Zig expects -target and
|
||||
# rejects Rust triples like *-unknown-linux-musl.
|
||||
if [[ "\${arg}" == "-target" ]]; then
|
||||
skip_next=1
|
||||
fi
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
args+=("\${arg}")
|
||||
done
|
||||
|
||||
exec "${zig_bin}" cc -target "${zig_target}" "\${args[@]}"
|
||||
EOF
|
||||
cat >"${cxx}" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
args=()
|
||||
skip_next=0
|
||||
for arg in "\$@"; do
|
||||
if [[ "\${skip_next}" -eq 1 ]]; then
|
||||
skip_next=0
|
||||
continue
|
||||
fi
|
||||
case "\${arg}" in
|
||||
--target)
|
||||
skip_next=1
|
||||
continue
|
||||
;;
|
||||
--target=*|-target=*|-target)
|
||||
if [[ "\${arg}" == "-target" ]]; then
|
||||
skip_next=1
|
||||
fi
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
args+=("\${arg}")
|
||||
done
|
||||
|
||||
exec "${zig_bin}" c++ -target "${zig_target}" "\${args[@]}"
|
||||
EOF
|
||||
chmod +x "${cc}" "${cxx}"
|
||||
|
||||
sysroot="$("${zig_bin}" cc -target "${zig_target}" -print-sysroot 2>/dev/null || true)"
|
||||
else
|
||||
cc="${musl_linker}"
|
||||
|
||||
if command -v "${arch}-linux-musl-g++" >/dev/null; then
|
||||
cxx="$(command -v "${arch}-linux-musl-g++")"
|
||||
elif command -v musl-g++ >/dev/null; then
|
||||
cxx="$(command -v musl-g++)"
|
||||
else
|
||||
cxx="${cc}"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n "${sysroot}" && "${sysroot}" != "/" ]]; then
|
||||
echo "BORING_BSSL_SYSROOT=${sysroot}" >> "$GITHUB_ENV"
|
||||
boring_sysroot_var="BORING_BSSL_SYSROOT_${TARGET}"
|
||||
boring_sysroot_var="${boring_sysroot_var//-/_}"
|
||||
echo "${boring_sysroot_var}=${sysroot}" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
cflags="-pthread"
|
||||
cxxflags="-pthread"
|
||||
if [[ "${TARGET}" == "aarch64-unknown-linux-musl" ]]; then
|
||||
# BoringSSL enables -Wframe-larger-than=25344 under clang and treats warnings as errors.
|
||||
cflags="${cflags} -Wno-error=frame-larger-than"
|
||||
cxxflags="${cxxflags} -Wno-error=frame-larger-than"
|
||||
fi
|
||||
|
||||
echo "CFLAGS=${cflags}" >> "$GITHUB_ENV"
|
||||
echo "CXXFLAGS=${cxxflags}" >> "$GITHUB_ENV"
|
||||
echo "CC=${cc}" >> "$GITHUB_ENV"
|
||||
echo "TARGET_CC=${cc}" >> "$GITHUB_ENV"
|
||||
target_cc_var="CC_${TARGET}"
|
||||
target_cc_var="${target_cc_var//-/_}"
|
||||
echo "${target_cc_var}=${cc}" >> "$GITHUB_ENV"
|
||||
echo "CXX=${cxx}" >> "$GITHUB_ENV"
|
||||
echo "TARGET_CXX=${cxx}" >> "$GITHUB_ENV"
|
||||
target_cxx_var="CXX_${TARGET}"
|
||||
target_cxx_var="${target_cxx_var//-/_}"
|
||||
echo "${target_cxx_var}=${cxx}" >> "$GITHUB_ENV"
|
||||
|
||||
cargo_linker_var="CARGO_TARGET_${TARGET^^}_LINKER"
|
||||
cargo_linker_var="${cargo_linker_var//-/_}"
|
||||
echo "${cargo_linker_var}=${musl_linker}" >> "$GITHUB_ENV"
|
||||
|
||||
echo "CMAKE_C_COMPILER=${cc}" >> "$GITHUB_ENV"
|
||||
echo "CMAKE_CXX_COMPILER=${cxx}" >> "$GITHUB_ENV"
|
||||
echo "CMAKE_ARGS=-DCMAKE_HAVE_THREADS_LIBRARY=1 -DCMAKE_USE_PTHREADS_INIT=1 -DCMAKE_THREAD_LIBS_INIT=-pthread -DTHREADS_PREFER_PTHREAD_FLAG=ON" >> "$GITHUB_ENV"
|
||||
|
||||
@@ -261,6 +261,12 @@ jobs:
|
||||
/var/cache/apt
|
||||
key: apt-${{ matrix.runner }}-${{ matrix.target }}-v1
|
||||
|
||||
- if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}}
|
||||
name: Install Zig
|
||||
uses: mlugg/setup-zig@v2
|
||||
with:
|
||||
version: 0.14.0
|
||||
|
||||
- if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}}
|
||||
name: Install musl build tools
|
||||
env:
|
||||
|
||||
@@ -104,6 +104,12 @@ jobs:
|
||||
${{ github.workspace }}/codex-rs/target/
|
||||
key: cargo-${{ matrix.runner }}-${{ matrix.target }}-release-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}}
|
||||
name: Install Zig
|
||||
uses: mlugg/setup-zig@v2
|
||||
with:
|
||||
version: 0.14.0
|
||||
|
||||
- if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}}
|
||||
name: Install musl build tools
|
||||
env:
|
||||
|
||||
@@ -97,6 +97,12 @@ jobs:
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- if: ${{ matrix.install_musl }}
|
||||
name: Install Zig
|
||||
uses: mlugg/setup-zig@v2
|
||||
with:
|
||||
version: 0.14.0
|
||||
|
||||
- if: ${{ matrix.install_musl }}
|
||||
name: Install musl build dependencies
|
||||
env:
|
||||
|
||||
Generated
+1
@@ -1096,6 +1096,7 @@ dependencies = [
|
||||
"serial_test",
|
||||
"shlex",
|
||||
"tempfile",
|
||||
"time",
|
||||
"tokio",
|
||||
"toml 0.9.5",
|
||||
"tracing",
|
||||
|
||||
@@ -5,6 +5,7 @@ use crate::protocol::common::AuthMode;
|
||||
use codex_protocol::account::PlanType;
|
||||
use codex_protocol::approvals::ExecPolicyAmendment as CoreExecPolicyAmendment;
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::config_types::CollaborationModeMask;
|
||||
use codex_protocol::config_types::ForcedLoginMethod;
|
||||
use codex_protocol::config_types::Personality;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
@@ -30,6 +31,7 @@ use codex_protocol::protocol::SkillErrorInfo as CoreSkillErrorInfo;
|
||||
use codex_protocol::protocol::SkillInterface as CoreSkillInterface;
|
||||
use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata;
|
||||
use codex_protocol::protocol::SkillScope as CoreSkillScope;
|
||||
use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource;
|
||||
use codex_protocol::protocol::TokenUsage as CoreTokenUsage;
|
||||
use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo;
|
||||
use codex_protocol::user_input::ByteRange as CoreByteRange;
|
||||
@@ -700,6 +702,7 @@ pub enum SessionSource {
|
||||
VsCode,
|
||||
Exec,
|
||||
AppServer,
|
||||
SubAgent(CoreSubAgentSource),
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
@@ -711,7 +714,7 @@ impl From<CoreSessionSource> for SessionSource {
|
||||
CoreSessionSource::VSCode => SessionSource::VsCode,
|
||||
CoreSessionSource::Exec => SessionSource::Exec,
|
||||
CoreSessionSource::Mcp => SessionSource::AppServer,
|
||||
CoreSessionSource::SubAgent(_) => SessionSource::Unknown,
|
||||
CoreSessionSource::SubAgent(sub) => SessionSource::SubAgent(sub),
|
||||
CoreSessionSource::Unknown => SessionSource::Unknown,
|
||||
}
|
||||
}
|
||||
@@ -724,6 +727,7 @@ impl From<SessionSource> for CoreSessionSource {
|
||||
SessionSource::VsCode => CoreSessionSource::VSCode,
|
||||
SessionSource::Exec => CoreSessionSource::Exec,
|
||||
SessionSource::AppServer => CoreSessionSource::Mcp,
|
||||
SessionSource::SubAgent(sub) => CoreSessionSource::SubAgent(sub),
|
||||
SessionSource::Unknown => CoreSessionSource::Unknown,
|
||||
}
|
||||
}
|
||||
@@ -934,7 +938,7 @@ pub struct CollaborationModeListParams {}
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct CollaborationModeListResponse {
|
||||
pub data: Vec<CollaborationMode>,
|
||||
pub data: Vec<CollaborationModeMask>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
@@ -1083,6 +1087,7 @@ pub struct ThreadStartParams {
|
||||
pub base_instructions: Option<String>,
|
||||
pub developer_instructions: Option<String>,
|
||||
pub personality: Option<Personality>,
|
||||
pub ephemeral: Option<bool>,
|
||||
/// If true, opt into emitting raw response items on the event stream.
|
||||
///
|
||||
/// This is for internal use only (e.g. Codex Cloud).
|
||||
@@ -1466,7 +1471,7 @@ pub struct Thread {
|
||||
#[ts(type = "number")]
|
||||
pub updated_at: i64,
|
||||
/// [UNSTABLE] Path to the thread on disk.
|
||||
pub path: PathBuf,
|
||||
pub path: Option<PathBuf>,
|
||||
/// Working directory captured for the thread.
|
||||
pub cwd: PathBuf,
|
||||
/// Version of the CLI that created the thread.
|
||||
|
||||
@@ -35,6 +35,7 @@ serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
mcp-types = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
time = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
tokio = { workspace = true, features = [
|
||||
"io-std",
|
||||
|
||||
@@ -1006,7 +1006,15 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
};
|
||||
|
||||
if let Some(request_id) = pending {
|
||||
let rollout_path = conversation.rollout_path();
|
||||
let Some(rollout_path) = conversation.rollout_path() else {
|
||||
let error = JSONRPCErrorError {
|
||||
code: INVALID_REQUEST_ERROR_CODE,
|
||||
message: "thread has no persisted rollout".to_string(),
|
||||
data: None,
|
||||
};
|
||||
outgoing.send_error(request_id, error).await;
|
||||
return;
|
||||
};
|
||||
let response = match read_summary_from_rollout(
|
||||
rollout_path.as_path(),
|
||||
fallback_model_provider.as_str(),
|
||||
|
||||
@@ -134,6 +134,7 @@ use codex_core::InitialHistory;
|
||||
use codex_core::NewThread;
|
||||
use codex_core::RolloutRecorder;
|
||||
use codex_core::SessionMeta;
|
||||
use codex_core::ThreadConfigSnapshot;
|
||||
use codex_core::ThreadManager;
|
||||
use codex_core::ThreadSortKey as CoreThreadSortKey;
|
||||
use codex_core::auth::CLIENT_ID;
|
||||
@@ -172,6 +173,7 @@ use codex_protocol::config_types::ForcedLoginMethod;
|
||||
use codex_protocol::config_types::Personality;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::AgentStatus;
|
||||
use codex_protocol::protocol::GitInfo as CoreGitInfo;
|
||||
use codex_protocol::protocol::McpAuthStatus as CoreMcpAuthStatus;
|
||||
use codex_protocol::protocol::McpServerRefreshConfig;
|
||||
@@ -192,7 +194,6 @@ use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
use tokio::select;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::sync::oneshot;
|
||||
@@ -1378,11 +1379,23 @@ impl CodexMessageProcessor {
|
||||
session_configured,
|
||||
..
|
||||
} = new_thread;
|
||||
let rollout_path = match session_configured.rollout_path {
|
||||
Some(path) => path,
|
||||
None => {
|
||||
let error = JSONRPCErrorError {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: "rollout path missing for v1 conversation".to_string(),
|
||||
data: None,
|
||||
};
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let response = NewConversationResponse {
|
||||
conversation_id: thread_id,
|
||||
model: session_configured.model,
|
||||
reasoning_effort: session_configured.reasoning_effort,
|
||||
rollout_path: session_configured.rollout_path,
|
||||
rollout_path,
|
||||
};
|
||||
self.outgoing.send_response(request_id, response).await;
|
||||
}
|
||||
@@ -1398,7 +1411,7 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
|
||||
async fn thread_start(&mut self, request_id: RequestId, params: ThreadStartParams) {
|
||||
let typesafe_overrides = self.build_thread_config_overrides(
|
||||
let mut typesafe_overrides = self.build_thread_config_overrides(
|
||||
params.model,
|
||||
params.model_provider,
|
||||
params.cwd,
|
||||
@@ -1408,6 +1421,7 @@ impl CodexMessageProcessor {
|
||||
params.developer_instructions,
|
||||
params.personality,
|
||||
);
|
||||
typesafe_overrides.ephemeral = Some(params.ephemeral.unwrap_or_default());
|
||||
|
||||
let config =
|
||||
match derive_config_from_params(&self.cli_overrides, params.config, typesafe_overrides)
|
||||
@@ -1429,50 +1443,45 @@ impl CodexMessageProcessor {
|
||||
Ok(new_conv) => {
|
||||
let NewThread {
|
||||
thread_id,
|
||||
thread,
|
||||
session_configured,
|
||||
..
|
||||
} = new_conv;
|
||||
let rollout_path = session_configured.rollout_path.clone();
|
||||
let config_snapshot = thread.config_snapshot().await;
|
||||
let fallback_provider = self.config.model_provider_id.as_str();
|
||||
|
||||
// A bit hacky, but the summary contains a lot of useful information for the thread
|
||||
// that unfortunately does not get returned from thread_manager.start_thread().
|
||||
let thread = match read_summary_from_rollout(
|
||||
rollout_path.as_path(),
|
||||
fallback_provider,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(summary) => summary_to_thread(summary),
|
||||
Err(err) => {
|
||||
self.send_internal_error(
|
||||
request_id,
|
||||
format!(
|
||||
"failed to load rollout `{}` for thread {thread_id}: {err}",
|
||||
rollout_path.display()
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
let thread = match session_configured.rollout_path.as_ref() {
|
||||
Some(rollout_path) => {
|
||||
match read_summary_from_rollout(rollout_path.as_path(), fallback_provider)
|
||||
.await
|
||||
{
|
||||
Ok(summary) => summary_to_thread(summary),
|
||||
Err(err) => {
|
||||
self.send_internal_error(
|
||||
request_id,
|
||||
format!(
|
||||
"failed to load rollout `{}` for thread {thread_id}: {err}",
|
||||
rollout_path.display()
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
None => build_ephemeral_thread(thread_id, &config_snapshot),
|
||||
};
|
||||
|
||||
let SessionConfiguredEvent {
|
||||
model,
|
||||
model_provider_id,
|
||||
cwd,
|
||||
approval_policy,
|
||||
sandbox_policy,
|
||||
..
|
||||
} = session_configured;
|
||||
let response = ThreadStartResponse {
|
||||
thread: thread.clone(),
|
||||
model,
|
||||
model_provider: model_provider_id,
|
||||
cwd,
|
||||
approval_policy: approval_policy.into(),
|
||||
sandbox: sandbox_policy.into(),
|
||||
reasoning_effort: session_configured.reasoning_effort,
|
||||
model: config_snapshot.model,
|
||||
model_provider: config_snapshot.model_provider_id,
|
||||
cwd: config_snapshot.cwd,
|
||||
approval_policy: config_snapshot.approval_policy.into(),
|
||||
sandbox: config_snapshot.sandbox_policy.into(),
|
||||
reasoning_effort: config_snapshot.reasoning_effort,
|
||||
};
|
||||
|
||||
// Auto-attach a thread listener when starting a thread.
|
||||
@@ -1725,7 +1734,7 @@ impl CodexMessageProcessor {
|
||||
self.outgoing.send_response(request_id, response).await;
|
||||
}
|
||||
|
||||
async fn thread_read(&self, request_id: RequestId, params: ThreadReadParams) {
|
||||
async fn thread_read(&mut self, request_id: RequestId, params: ThreadReadParams) {
|
||||
let ThreadReadParams {
|
||||
thread_id,
|
||||
include_turns,
|
||||
@@ -1744,15 +1753,8 @@ impl CodexMessageProcessor {
|
||||
match find_thread_path_by_id_str(&self.config.codex_home, &thread_uuid.to_string())
|
||||
.await
|
||||
{
|
||||
Ok(Some(path)) => path,
|
||||
Ok(None) => {
|
||||
self.send_invalid_request_error(
|
||||
request_id,
|
||||
format!("no rollout found for thread id {thread_uuid}"),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
Ok(Some(path)) => Some(path),
|
||||
Ok(None) => None,
|
||||
Err(err) => {
|
||||
self.send_invalid_request_error(
|
||||
request_id,
|
||||
@@ -1763,24 +1765,45 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
};
|
||||
|
||||
let fallback_provider = self.config.model_provider_id.as_str();
|
||||
let mut thread = match read_summary_from_rollout(&rollout_path, fallback_provider).await {
|
||||
Ok(summary) => summary_to_thread(summary),
|
||||
Err(err) => {
|
||||
self.send_internal_error(
|
||||
let mut thread = if let Some(rollout_path) = rollout_path.as_ref() {
|
||||
let fallback_provider = self.config.model_provider_id.as_str();
|
||||
match read_summary_from_rollout(rollout_path, fallback_provider).await {
|
||||
Ok(summary) => summary_to_thread(summary),
|
||||
Err(err) => {
|
||||
self.send_internal_error(
|
||||
request_id,
|
||||
format!(
|
||||
"failed to load rollout `{}` for thread {thread_uuid}: {err}",
|
||||
rollout_path.display()
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let Ok(thread) = self.thread_manager.get_thread(thread_uuid).await else {
|
||||
self.send_invalid_request_error(
|
||||
request_id,
|
||||
format!(
|
||||
"failed to load rollout `{}` for thread {thread_uuid}: {err}",
|
||||
rollout_path.display()
|
||||
),
|
||||
format!("thread not loaded: {thread_uuid}"),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
};
|
||||
let config_snapshot = thread.config_snapshot().await;
|
||||
if include_turns {
|
||||
self.send_invalid_request_error(
|
||||
request_id,
|
||||
"ephemeral threads do not support includeTurns".to_string(),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
build_ephemeral_thread(thread_uuid, &config_snapshot)
|
||||
};
|
||||
|
||||
if include_turns {
|
||||
match read_event_msgs_from_rollout(&rollout_path).await {
|
||||
if include_turns && let Some(rollout_path) = rollout_path.as_ref() {
|
||||
match read_event_msgs_from_rollout(rollout_path).await {
|
||||
Ok(events) => {
|
||||
thread.turns = build_turns_from_event_msgs(&events);
|
||||
}
|
||||
@@ -1967,6 +1990,14 @@ impl CodexMessageProcessor {
|
||||
initial_messages,
|
||||
..
|
||||
} = session_configured;
|
||||
let Some(rollout_path) = rollout_path else {
|
||||
self.send_internal_error(
|
||||
request_id,
|
||||
format!("rollout path missing for thread {thread_id}"),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
};
|
||||
// Auto-attach a thread listener when resuming a thread.
|
||||
if let Err(err) = self
|
||||
.attach_conversation_listener(thread_id, false, ApiVersion::V2)
|
||||
@@ -2170,6 +2201,14 @@ impl CodexMessageProcessor {
|
||||
initial_messages,
|
||||
..
|
||||
} = session_configured;
|
||||
let Some(rollout_path) = rollout_path else {
|
||||
self.send_internal_error(
|
||||
request_id,
|
||||
format!("rollout path missing for thread {thread_id}"),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
};
|
||||
// Auto-attach a conversation listener when forking a thread.
|
||||
if let Err(err) = self
|
||||
.attach_conversation_listener(thread_id, false, ApiVersion::V2)
|
||||
@@ -2919,6 +2958,18 @@ impl CodexMessageProcessor {
|
||||
session_configured,
|
||||
..
|
||||
}) => {
|
||||
let rollout_path = match session_configured.rollout_path.clone() {
|
||||
Some(path) => path,
|
||||
None => {
|
||||
let error = JSONRPCErrorError {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: "rollout path missing for resumed conversation".to_string(),
|
||||
data: None,
|
||||
};
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
self.outgoing
|
||||
.send_server_notification(ServerNotification::SessionConfigured(
|
||||
SessionConfiguredNotification {
|
||||
@@ -2928,7 +2979,7 @@ impl CodexMessageProcessor {
|
||||
history_log_id: session_configured.history_log_id,
|
||||
history_entry_count: session_configured.history_entry_count,
|
||||
initial_messages: session_configured.initial_messages.clone(),
|
||||
rollout_path: session_configured.rollout_path.clone(),
|
||||
rollout_path: rollout_path.clone(),
|
||||
},
|
||||
))
|
||||
.await;
|
||||
@@ -2941,7 +2992,7 @@ impl CodexMessageProcessor {
|
||||
conversation_id: thread_id,
|
||||
model: session_configured.model.clone(),
|
||||
initial_messages,
|
||||
rollout_path: session_configured.rollout_path.clone(),
|
||||
rollout_path,
|
||||
};
|
||||
self.outgoing.send_response(request_id, response).await;
|
||||
}
|
||||
@@ -3117,6 +3168,19 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
};
|
||||
|
||||
let rollout_path = match session_configured.rollout_path.clone() {
|
||||
Some(path) => path,
|
||||
None => {
|
||||
let error = JSONRPCErrorError {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: "rollout path missing for forked conversation".to_string(),
|
||||
data: None,
|
||||
};
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
self.outgoing
|
||||
.send_server_notification(ServerNotification::SessionConfigured(
|
||||
SessionConfiguredNotification {
|
||||
@@ -3126,7 +3190,7 @@ impl CodexMessageProcessor {
|
||||
history_log_id: session_configured.history_log_id,
|
||||
history_entry_count: session_configured.history_entry_count,
|
||||
initial_messages: session_configured.initial_messages.clone(),
|
||||
rollout_path: session_configured.rollout_path.clone(),
|
||||
rollout_path: rollout_path.clone(),
|
||||
},
|
||||
))
|
||||
.await;
|
||||
@@ -3139,7 +3203,7 @@ impl CodexMessageProcessor {
|
||||
conversation_id: thread_id,
|
||||
model: session_configured.model.clone(),
|
||||
initial_messages,
|
||||
rollout_path: session_configured.rollout_path.clone(),
|
||||
rollout_path,
|
||||
};
|
||||
self.outgoing.send_response(request_id, response).await;
|
||||
}
|
||||
@@ -3250,51 +3314,27 @@ impl CodexMessageProcessor {
|
||||
// If the thread is active, request shutdown and wait briefly.
|
||||
if let Some(conversation) = self.thread_manager.remove_thread(&thread_id).await {
|
||||
info!("thread {thread_id} was active; shutting down");
|
||||
let conversation_clone = conversation.clone();
|
||||
let notify = Arc::new(tokio::sync::Notify::new());
|
||||
let notify_clone = notify.clone();
|
||||
|
||||
// Establish the listener for ShutdownComplete before submitting
|
||||
// Shutdown so it is not missed.
|
||||
let is_shutdown = tokio::spawn(async move {
|
||||
// Create the notified future outside the loop to avoid losing notifications.
|
||||
let notified = notify_clone.notified();
|
||||
tokio::pin!(notified);
|
||||
loop {
|
||||
select! {
|
||||
_ = &mut notified => { break; }
|
||||
event = conversation_clone.next_event() => {
|
||||
match event {
|
||||
Ok(event) => {
|
||||
if matches!(event.msg, EventMsg::ShutdownComplete) { break; }
|
||||
}
|
||||
// Break on errors to avoid tight loops when the agent loop has exited.
|
||||
Err(_) => { break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
// Request shutdown.
|
||||
match conversation.submit(Op::Shutdown).await {
|
||||
Ok(_) => {
|
||||
// Successfully submitted Shutdown; wait before proceeding.
|
||||
select! {
|
||||
_ = is_shutdown => {
|
||||
// Normal shutdown: proceed with archive.
|
||||
}
|
||||
_ = tokio::time::sleep(Duration::from_secs(10)) => {
|
||||
warn!("thread {thread_id} shutdown timed out; proceeding with archive");
|
||||
// Wake any waiter; use notify_waiters to avoid missing the signal.
|
||||
notify.notify_waiters();
|
||||
// Perhaps we lost a shutdown race, so let's continue to
|
||||
// clean up the .jsonl file.
|
||||
// Poll agent status rather than consuming events so attached listeners do not block shutdown.
|
||||
let wait_for_shutdown = async {
|
||||
loop {
|
||||
if matches!(conversation.agent_status().await, AgentStatus::Shutdown) {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
};
|
||||
if tokio::time::timeout(Duration::from_secs(10), wait_for_shutdown)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
warn!("thread {thread_id} shutdown timed out; proceeding with archive");
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
error!("failed to submit Shutdown to thread {thread_id}: {err}");
|
||||
notify.notify_waiters();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3809,23 +3849,29 @@ impl CodexMessageProcessor {
|
||||
);
|
||||
}
|
||||
|
||||
let rollout_path = review_thread.rollout_path();
|
||||
let fallback_provider = self.config.model_provider_id.as_str();
|
||||
match read_summary_from_rollout(rollout_path.as_path(), fallback_provider).await {
|
||||
Ok(summary) => {
|
||||
let thread = summary_to_thread(summary);
|
||||
let notif = ThreadStartedNotification { thread };
|
||||
self.outgoing
|
||||
.send_server_notification(ServerNotification::ThreadStarted(notif))
|
||||
.await;
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
"failed to load summary for review thread {}: {}",
|
||||
session_configured.session_id,
|
||||
err
|
||||
);
|
||||
if let Some(rollout_path) = review_thread.rollout_path() {
|
||||
match read_summary_from_rollout(rollout_path.as_path(), fallback_provider).await {
|
||||
Ok(summary) => {
|
||||
let thread = summary_to_thread(summary);
|
||||
let notif = ThreadStartedNotification { thread };
|
||||
self.outgoing
|
||||
.send_server_notification(ServerNotification::ThreadStarted(notif))
|
||||
.await;
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
"failed to load summary for review thread {}: {}",
|
||||
session_configured.session_id,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"review thread {} has no rollout path",
|
||||
session_configured.session_id
|
||||
);
|
||||
}
|
||||
|
||||
let turn_id = review_thread
|
||||
@@ -4228,7 +4274,7 @@ impl CodexMessageProcessor {
|
||||
|
||||
async fn resolve_rollout_path(&self, conversation_id: ThreadId) -> Option<PathBuf> {
|
||||
match self.thread_manager.get_thread(conversation_id).await {
|
||||
Ok(conv) => Some(conv.rollout_path()),
|
||||
Ok(conv) => conv.rollout_path(),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
@@ -4493,6 +4539,23 @@ async fn read_updated_at(path: &Path, created_at: Option<&str>) -> Option<String
|
||||
updated_at.or_else(|| created_at.map(str::to_string))
|
||||
}
|
||||
|
||||
fn build_ephemeral_thread(thread_id: ThreadId, config_snapshot: &ThreadConfigSnapshot) -> Thread {
|
||||
let now = time::OffsetDateTime::now_utc().unix_timestamp();
|
||||
Thread {
|
||||
id: thread_id.to_string(),
|
||||
preview: String::new(),
|
||||
model_provider: config_snapshot.model_provider_id.clone(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
path: None,
|
||||
cwd: config_snapshot.cwd.clone(),
|
||||
cli_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
source: config_snapshot.session_source.clone().into(),
|
||||
git_info: None,
|
||||
turns: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn summary_to_thread(summary: ConversationSummary) -> Thread {
|
||||
let ConversationSummary {
|
||||
conversation_id,
|
||||
@@ -4521,7 +4584,7 @@ pub(crate) fn summary_to_thread(summary: ConversationSummary) -> Thread {
|
||||
model_provider,
|
||||
created_at: created_at.map(|dt| dt.timestamp()).unwrap_or(0),
|
||||
updated_at: updated_at.map(|dt| dt.timestamp()).unwrap_or(0),
|
||||
path,
|
||||
path: Some(path),
|
||||
cwd,
|
||||
cli_version,
|
||||
source: source.into(),
|
||||
|
||||
@@ -16,7 +16,7 @@ use codex_app_server_protocol::CollaborationModeListResponse;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_core::models_manager::test_builtin_collaboration_mode_presets;
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::config_types::CollaborationModeMask;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
@@ -45,13 +45,23 @@ async fn list_collaboration_modes_returns_presets() -> Result<()> {
|
||||
let CollaborationModeListResponse { data: items } =
|
||||
to_response::<CollaborationModeListResponse>(response)?;
|
||||
|
||||
let expected = vec![
|
||||
let expected = [
|
||||
plan_preset(),
|
||||
code_preset(),
|
||||
pair_programming_preset(),
|
||||
execute_preset(),
|
||||
];
|
||||
assert_eq!(expected, items);
|
||||
assert_eq!(expected.len(), items.len());
|
||||
for (expected_mask, actual_mask) in expected.iter().zip(items.iter()) {
|
||||
assert_eq!(expected_mask.name, actual_mask.name);
|
||||
assert_eq!(expected_mask.mode, actual_mask.mode);
|
||||
assert_eq!(expected_mask.model, actual_mask.model);
|
||||
assert_eq!(expected_mask.reasoning_effort, actual_mask.reasoning_effort);
|
||||
assert_eq!(
|
||||
expected_mask.developer_instructions,
|
||||
actual_mask.developer_instructions
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -59,11 +69,11 @@ async fn list_collaboration_modes_returns_presets() -> Result<()> {
|
||||
///
|
||||
/// If the defaults change in the app server, this helper should be updated alongside the
|
||||
/// contract, or the test will fail in ways that imply a regression in the API.
|
||||
fn plan_preset() -> CollaborationMode {
|
||||
fn plan_preset() -> CollaborationModeMask {
|
||||
let presets = test_builtin_collaboration_mode_presets();
|
||||
presets
|
||||
.into_iter()
|
||||
.find(|p| p.mode == ModeKind::Plan)
|
||||
.find(|p| p.mode == Some(ModeKind::Plan))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
@@ -71,20 +81,20 @@ fn plan_preset() -> CollaborationMode {
|
||||
///
|
||||
/// The helper keeps the expected model and reasoning defaults co-located with the test
|
||||
/// so that mismatches point directly at the API contract being exercised.
|
||||
fn pair_programming_preset() -> CollaborationMode {
|
||||
fn pair_programming_preset() -> CollaborationModeMask {
|
||||
let presets = test_builtin_collaboration_mode_presets();
|
||||
presets
|
||||
.into_iter()
|
||||
.find(|p| p.mode == ModeKind::PairProgramming)
|
||||
.find(|p| p.mode == Some(ModeKind::PairProgramming))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Builds the code preset that the list response is expected to return.
|
||||
fn code_preset() -> CollaborationMode {
|
||||
fn code_preset() -> CollaborationModeMask {
|
||||
let presets = test_builtin_collaboration_mode_presets();
|
||||
presets
|
||||
.into_iter()
|
||||
.find(|p| p.mode == ModeKind::Code)
|
||||
.find(|p| p.mode == Some(ModeKind::Code))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
@@ -92,10 +102,10 @@ fn code_preset() -> CollaborationMode {
|
||||
///
|
||||
/// The execute preset uses a different reasoning effort to capture the higher-effort
|
||||
/// execution contract the server currently exposes.
|
||||
fn execute_preset() -> CollaborationMode {
|
||||
fn execute_preset() -> CollaborationModeMask {
|
||||
let presets = test_builtin_collaboration_mode_presets();
|
||||
presets
|
||||
.into_iter()
|
||||
.find(|p| p.mode == ModeKind::Execute)
|
||||
.find(|p| p.mode == Some(ModeKind::Execute))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
@@ -77,8 +77,9 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> {
|
||||
assert_ne!(thread.id, conversation_id);
|
||||
assert_eq!(thread.preview, preview);
|
||||
assert_eq!(thread.model_provider, "mock_provider");
|
||||
assert!(thread.path.is_absolute());
|
||||
assert_ne!(thread.path, original_path);
|
||||
let thread_path = thread.path.clone().expect("thread path");
|
||||
assert!(thread_path.is_absolute());
|
||||
assert_ne!(thread_path, original_path);
|
||||
assert!(thread.cwd.is_absolute());
|
||||
assert_eq!(thread.source, SessionSource::VsCode);
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ async fn thread_read_returns_summary_without_turns() -> Result<()> {
|
||||
assert_eq!(thread.id, conversation_id);
|
||||
assert_eq!(thread.preview, preview);
|
||||
assert_eq!(thread.model_provider, "mock_provider");
|
||||
assert!(thread.path.is_absolute());
|
||||
assert!(thread.path.as_ref().expect("thread path").is_absolute());
|
||||
assert_eq!(thread.cwd, PathBuf::from("/"));
|
||||
assert_eq!(thread.cli_version, "0.0.0");
|
||||
assert_eq!(thread.source, SessionSource::Cli);
|
||||
|
||||
@@ -117,7 +117,7 @@ async fn thread_resume_returns_rollout_history() -> Result<()> {
|
||||
assert_eq!(thread.id, conversation_id);
|
||||
assert_eq!(thread.preview, preview);
|
||||
assert_eq!(thread.model_provider, "mock_provider");
|
||||
assert!(thread.path.is_absolute());
|
||||
assert!(thread.path.as_ref().expect("thread path").is_absolute());
|
||||
assert_eq!(thread.cwd, PathBuf::from("/"));
|
||||
assert_eq!(thread.cli_version, "0.0.0");
|
||||
assert_eq!(thread.source, SessionSource::Cli);
|
||||
@@ -169,7 +169,7 @@ async fn thread_resume_prefers_path_over_thread_id() -> Result<()> {
|
||||
.await??;
|
||||
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(start_resp)?;
|
||||
|
||||
let thread_path = thread.path.clone();
|
||||
let thread_path = thread.path.clone().expect("thread path");
|
||||
let resume_id = mcp
|
||||
.send_thread_resume_request(ThreadResumeParams {
|
||||
thread_id: "not-a-valid-thread-id".to_string(),
|
||||
|
||||
@@ -396,8 +396,7 @@ fn run_update_action(action: UpdateAction) -> anyhow::Result<()> {
|
||||
if !status.success() {
|
||||
anyhow::bail!("`{cmd_str}` failed with status {status}");
|
||||
}
|
||||
println!();
|
||||
println!("🎉 Update ran successfully! Please restart Codex.");
|
||||
println!("\n🎉 Update ran successfully! Please restart Codex.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -15,13 +15,12 @@ pub(crate) fn subagent_header(source: &Option<SessionSource>) -> Option<String>
|
||||
return None;
|
||||
};
|
||||
match sub {
|
||||
codex_protocol::protocol::SubAgentSource::Review => Some("review".to_string()),
|
||||
codex_protocol::protocol::SubAgentSource::Compact => Some("compact".to_string()),
|
||||
codex_protocol::protocol::SubAgentSource::ThreadSpawn { .. } => {
|
||||
Some("collab_spawn".to_string())
|
||||
}
|
||||
codex_protocol::protocol::SubAgentSource::Other(label) => Some(label.clone()),
|
||||
other => Some(
|
||||
serde_json::to_value(other)
|
||||
.ok()
|
||||
.and_then(|v| v.as_str().map(std::string::ToString::to_string))
|
||||
.unwrap_or_else(|| "other".to_string()),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,12 +39,20 @@ impl AgentControl {
|
||||
&self,
|
||||
config: crate::config::Config,
|
||||
prompt: String,
|
||||
session_source: Option<codex_protocol::protocol::SessionSource>,
|
||||
) -> CodexResult<ThreadId> {
|
||||
let state = self.upgrade()?;
|
||||
let reservation = self.state.reserve_spawn_slot(config.agent_max_threads)?;
|
||||
|
||||
// The same `AgentControl` is sent to spawn the thread.
|
||||
let new_thread = state.spawn_new_thread(config, self.clone()).await?;
|
||||
let new_thread = match session_source {
|
||||
Some(session_source) => {
|
||||
state
|
||||
.spawn_new_thread_with_source(config, self.clone(), session_source)
|
||||
.await?
|
||||
}
|
||||
None => state.spawn_new_thread(config, self.clone()).await?,
|
||||
};
|
||||
reservation.commit(new_thread.thread_id);
|
||||
|
||||
// Notify a new thread has been created. This notification will be processed by clients
|
||||
@@ -268,7 +276,7 @@ mod tests {
|
||||
let control = AgentControl::default();
|
||||
let (_home, config) = test_config().await;
|
||||
let err = control
|
||||
.spawn_agent(config, "hello".to_string())
|
||||
.spawn_agent(config, "hello".to_string(), None)
|
||||
.await
|
||||
.expect_err("spawn_agent should fail without a manager");
|
||||
assert_eq!(
|
||||
@@ -370,7 +378,7 @@ mod tests {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
let thread_id = harness
|
||||
.control
|
||||
.spawn_agent(harness.config.clone(), "spawned".to_string())
|
||||
.spawn_agent(harness.config.clone(), "spawned".to_string(), None)
|
||||
.await
|
||||
.expect("spawn_agent should succeed");
|
||||
let _thread = harness
|
||||
@@ -417,12 +425,12 @@ mod tests {
|
||||
.expect("start thread");
|
||||
|
||||
let first_agent_id = control
|
||||
.spawn_agent(config.clone(), "hello".to_string())
|
||||
.spawn_agent(config.clone(), "hello".to_string(), None)
|
||||
.await
|
||||
.expect("spawn_agent should succeed");
|
||||
|
||||
let err = control
|
||||
.spawn_agent(config, "hello again".to_string())
|
||||
.spawn_agent(config, "hello again".to_string(), None)
|
||||
.await
|
||||
.expect_err("spawn_agent should respect max threads");
|
||||
let CodexErr::AgentLimitReached {
|
||||
@@ -455,7 +463,7 @@ mod tests {
|
||||
let control = manager.agent_control();
|
||||
|
||||
let first_agent_id = control
|
||||
.spawn_agent(config.clone(), "hello".to_string())
|
||||
.spawn_agent(config.clone(), "hello".to_string(), None)
|
||||
.await
|
||||
.expect("spawn_agent should succeed");
|
||||
let _ = control
|
||||
@@ -464,7 +472,7 @@ mod tests {
|
||||
.expect("shutdown agent");
|
||||
|
||||
let second_agent_id = control
|
||||
.spawn_agent(config.clone(), "hello again".to_string())
|
||||
.spawn_agent(config.clone(), "hello again".to_string(), None)
|
||||
.await
|
||||
.expect("spawn_agent should succeed after shutdown");
|
||||
let _ = control
|
||||
@@ -490,12 +498,12 @@ mod tests {
|
||||
let cloned = control.clone();
|
||||
|
||||
let first_agent_id = cloned
|
||||
.spawn_agent(config.clone(), "hello".to_string())
|
||||
.spawn_agent(config.clone(), "hello".to_string(), None)
|
||||
.await
|
||||
.expect("spawn_agent should succeed");
|
||||
|
||||
let err = control
|
||||
.spawn_agent(config, "hello again".to_string())
|
||||
.spawn_agent(config, "hello again".to_string(), None)
|
||||
.await
|
||||
.expect_err("spawn_agent should respect shared guard");
|
||||
let CodexErr::AgentLimitReached { max_threads } = err else {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use crate::error::CodexErr;
|
||||
use crate::error::Result;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
@@ -19,6 +21,25 @@ pub(crate) struct Guards {
|
||||
total_count: AtomicUsize,
|
||||
}
|
||||
|
||||
/// Initial agent is depth 0.
|
||||
pub(crate) const MAX_THREAD_SPAWN_DEPTH: i32 = 1;
|
||||
|
||||
fn session_depth(session_source: &SessionSource) -> i32 {
|
||||
match session_source {
|
||||
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) => *depth,
|
||||
SessionSource::SubAgent(_) => 0,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn next_thread_spawn_depth(session_source: &SessionSource) -> i32 {
|
||||
session_depth(session_source).saturating_add(1)
|
||||
}
|
||||
|
||||
pub(crate) fn exceeds_thread_spawn_depth_limit(depth: i32) -> bool {
|
||||
depth > MAX_THREAD_SPAWN_DEPTH
|
||||
}
|
||||
|
||||
impl Guards {
|
||||
pub(crate) fn reserve_spawn_slot(
|
||||
self: &Arc<Self>,
|
||||
@@ -102,6 +123,30 @@ mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn session_depth_defaults_to_zero_for_root_sources() {
|
||||
assert_eq!(session_depth(&SessionSource::Cli), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_spawn_depth_increments_and_enforces_limit() {
|
||||
let session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: ThreadId::new(),
|
||||
depth: 1,
|
||||
});
|
||||
let child_depth = next_thread_spawn_depth(&session_source);
|
||||
assert_eq!(child_depth, 2);
|
||||
assert!(exceeds_thread_spawn_depth_limit(child_depth));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_thread_spawn_subagents_default_to_depth_zero() {
|
||||
let session_source = SessionSource::SubAgent(SubAgentSource::Review);
|
||||
assert_eq!(session_depth(&session_source), 0);
|
||||
assert_eq!(next_thread_spawn_depth(&session_source), 1);
|
||||
assert!(!exceeds_thread_spawn_depth_limit(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reservation_drop_releases_slot() {
|
||||
let guards = Arc::new(Guards::default());
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
pub(crate) mod control;
|
||||
// Do not put in `pub` or `pub(crate)`. This code should not be used somewhere else.
|
||||
mod guards;
|
||||
pub(crate) mod role;
|
||||
pub(crate) mod status;
|
||||
|
||||
pub(crate) use codex_protocol::protocol::AgentStatus;
|
||||
pub(crate) use control::AgentControl;
|
||||
pub(crate) use guards::MAX_THREAD_SPAWN_DEPTH;
|
||||
pub(crate) use guards::exceeds_thread_spawn_depth_limit;
|
||||
pub(crate) use guards::next_thread_spawn_depth;
|
||||
pub(crate) use role::AgentRole;
|
||||
pub(crate) use status::agent_status_from_event;
|
||||
|
||||
@@ -226,13 +226,11 @@ impl ModelClient {
|
||||
|
||||
let mut extra_headers = ApiHeaderMap::new();
|
||||
if let SessionSource::SubAgent(sub) = &self.state.session_source {
|
||||
let subagent = if let crate::protocol::SubAgentSource::Other(label) = sub {
|
||||
label.clone()
|
||||
} else {
|
||||
serde_json::to_value(sub)
|
||||
.ok()
|
||||
.and_then(|v| v.as_str().map(std::string::ToString::to_string))
|
||||
.unwrap_or_else(|| "other".to_string())
|
||||
let subagent = match sub {
|
||||
crate::protocol::SubAgentSource::Review => "review".to_string(),
|
||||
crate::protocol::SubAgentSource::Compact => "compact".to_string(),
|
||||
crate::protocol::SubAgentSource::ThreadSpawn { .. } => "collab_spawn".to_string(),
|
||||
crate::protocol::SubAgentSource::Other(label) => label.clone(),
|
||||
};
|
||||
if let Ok(val) = HeaderValue::from_str(&subagent) {
|
||||
extra_headers.insert("x-openai-subagent", val);
|
||||
|
||||
@@ -89,6 +89,7 @@ use crate::client::ModelClient;
|
||||
use crate::client::ModelClientSession;
|
||||
use crate::client_common::Prompt;
|
||||
use crate::client_common::ResponseEvent;
|
||||
use crate::codex_thread::ThreadConfigSnapshot;
|
||||
use crate::compact::collect_user_messages;
|
||||
use crate::config::Config;
|
||||
use crate::config::Constrained;
|
||||
@@ -192,6 +193,7 @@ pub struct Codex {
|
||||
pub(crate) rx_event: Receiver<Event>,
|
||||
// Last known status of the agent.
|
||||
pub(crate) agent_status: watch::Receiver<AgentStatus>,
|
||||
pub(crate) session: Arc<Session>,
|
||||
}
|
||||
|
||||
/// Wrapper returned by [`Codex::spawn`] containing the spawned [`Codex`],
|
||||
@@ -345,12 +347,13 @@ impl Codex {
|
||||
let thread_id = session.conversation_id;
|
||||
|
||||
// This task will run until Op::Shutdown is received.
|
||||
tokio::spawn(submission_loop(session, config, rx_sub));
|
||||
tokio::spawn(submission_loop(Arc::clone(&session), config, rx_sub));
|
||||
let codex = Codex {
|
||||
next_id: AtomicU64::new(0),
|
||||
tx_sub,
|
||||
rx_event,
|
||||
agent_status: agent_status_rx,
|
||||
session,
|
||||
};
|
||||
|
||||
#[allow(deprecated)]
|
||||
@@ -394,6 +397,11 @@ impl Codex {
|
||||
pub(crate) async fn agent_status(&self) -> AgentStatus {
|
||||
self.agent_status.borrow().clone()
|
||||
}
|
||||
|
||||
pub(crate) async fn thread_config_snapshot(&self) -> ThreadConfigSnapshot {
|
||||
let state = self.session.state.lock().await;
|
||||
state.session_configuration.thread_config_snapshot()
|
||||
}
|
||||
}
|
||||
|
||||
/// Context for an initialized model agent
|
||||
@@ -495,6 +503,19 @@ pub(crate) struct SessionConfiguration {
|
||||
}
|
||||
|
||||
impl SessionConfiguration {
|
||||
fn thread_config_snapshot(&self) -> ThreadConfigSnapshot {
|
||||
ThreadConfigSnapshot {
|
||||
model: self.collaboration_mode.model().to_string(),
|
||||
model_provider_id: self.original_config_do_not_use.model_provider_id.clone(),
|
||||
approval_policy: self.approval_policy.value(),
|
||||
sandbox_policy: self.sandbox_policy.get().clone(),
|
||||
cwd: self.cwd.clone(),
|
||||
reasoning_effort: self.collaboration_mode.reasoning_effort(),
|
||||
personality: self.personality,
|
||||
session_source: self.session_source.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn apply(&self, updates: &SessionSettingsUpdate) -> ConstraintResult<Self> {
|
||||
let mut next_configuration = self.clone();
|
||||
if let Some(collaboration_mode) = updates.collaboration_mode.clone() {
|
||||
@@ -652,7 +673,15 @@ impl Session {
|
||||
// - initialize RolloutRecorder with new or resumed session info
|
||||
// - perform default shell discovery
|
||||
// - load history metadata
|
||||
let rollout_fut = RolloutRecorder::new(&config, rollout_params);
|
||||
let rollout_fut = async {
|
||||
if config.ephemeral {
|
||||
Ok(None)
|
||||
} else {
|
||||
RolloutRecorder::new(&config, rollout_params)
|
||||
.await
|
||||
.map(Some)
|
||||
}
|
||||
};
|
||||
|
||||
let history_meta_fut = crate::message_history::history_metadata(&config);
|
||||
let auth_manager_clone = Arc::clone(&auth_manager);
|
||||
@@ -679,7 +708,9 @@ impl Session {
|
||||
error!("failed to initialize rollout recorder: {e:#}");
|
||||
anyhow::Error::from(e)
|
||||
})?;
|
||||
let rollout_path = rollout_recorder.rollout_path.clone();
|
||||
let rollout_path = rollout_recorder
|
||||
.as_ref()
|
||||
.map(|rec| rec.rollout_path.clone());
|
||||
|
||||
let mut post_session_configured_events = Vec::<Event>::new();
|
||||
|
||||
@@ -768,7 +799,7 @@ impl Session {
|
||||
mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()),
|
||||
unified_exec_manager: UnifiedExecProcessManager::default(),
|
||||
notifier: UserNotifier::new(config.notify.clone()),
|
||||
rollout: Mutex::new(Some(rollout_recorder)),
|
||||
rollout: Mutex::new(rollout_recorder),
|
||||
user_shell: Arc::new(default_shell),
|
||||
show_raw_agent_reasoning: config.show_raw_agent_reasoning,
|
||||
exec_policy,
|
||||
|
||||
@@ -92,6 +92,7 @@ pub(crate) async fn run_codex_thread_interactive(
|
||||
tx_sub: tx_ops,
|
||||
rx_event: rx_sub,
|
||||
agent_status: codex.agent_status.clone(),
|
||||
session: Arc::clone(&codex.session),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -134,6 +135,7 @@ pub(crate) async fn run_codex_thread_one_shot(
|
||||
let (tx_bridge, rx_bridge) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
|
||||
let ops_tx = io.tx_sub.clone();
|
||||
let agent_status = io.agent_status.clone();
|
||||
let session = Arc::clone(&io.session);
|
||||
let io_for_bridge = io;
|
||||
tokio::spawn(async move {
|
||||
while let Ok(event) = io_for_bridge.next_event().await {
|
||||
@@ -166,6 +168,7 @@ pub(crate) async fn run_codex_thread_one_shot(
|
||||
rx_event: rx_bridge,
|
||||
tx_sub: tx_closed,
|
||||
agent_status,
|
||||
session,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -442,15 +445,15 @@ mod tests {
|
||||
let (tx_events, rx_events) = bounded(1);
|
||||
let (tx_sub, rx_sub) = bounded(SUBMISSION_CHANNEL_CAPACITY);
|
||||
let (_agent_status_tx, agent_status) = watch::channel(AgentStatus::PendingInit);
|
||||
let (session, ctx, _rx_evt) = crate::codex::make_session_and_context_with_rx().await;
|
||||
let codex = Arc::new(Codex {
|
||||
next_id: AtomicU64::new(0),
|
||||
tx_sub,
|
||||
rx_event: rx_events,
|
||||
agent_status,
|
||||
session: Arc::clone(&session),
|
||||
});
|
||||
|
||||
let (session, ctx, _rx_evt) = crate::codex::make_session_and_context_with_rx().await;
|
||||
|
||||
let (tx_out, rx_out) = bounded(1);
|
||||
tx_out
|
||||
.send(Event {
|
||||
|
||||
@@ -4,18 +4,35 @@ use crate::error::Result as CodexResult;
|
||||
use crate::protocol::Event;
|
||||
use crate::protocol::Op;
|
||||
use crate::protocol::Submission;
|
||||
use codex_protocol::config_types::Personality;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use std::path::PathBuf;
|
||||
use tokio::sync::watch;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ThreadConfigSnapshot {
|
||||
pub model: String,
|
||||
pub model_provider_id: String,
|
||||
pub approval_policy: AskForApproval,
|
||||
pub sandbox_policy: SandboxPolicy,
|
||||
pub cwd: PathBuf,
|
||||
pub reasoning_effort: Option<ReasoningEffort>,
|
||||
pub personality: Option<Personality>,
|
||||
pub session_source: SessionSource,
|
||||
}
|
||||
|
||||
pub struct CodexThread {
|
||||
codex: Codex,
|
||||
rollout_path: PathBuf,
|
||||
rollout_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Conduit for the bidirectional stream of messages that compose a thread
|
||||
/// (formerly called a conversation) in Codex.
|
||||
impl CodexThread {
|
||||
pub(crate) fn new(codex: Codex, rollout_path: PathBuf) -> Self {
|
||||
pub(crate) fn new(codex: Codex, rollout_path: Option<PathBuf>) -> Self {
|
||||
Self {
|
||||
codex,
|
||||
rollout_path,
|
||||
@@ -43,7 +60,11 @@ impl CodexThread {
|
||||
self.codex.agent_status.clone()
|
||||
}
|
||||
|
||||
pub fn rollout_path(&self) -> PathBuf {
|
||||
pub fn rollout_path(&self) -> Option<PathBuf> {
|
||||
self.rollout_path.clone()
|
||||
}
|
||||
|
||||
pub async fn config_snapshot(&self) -> ThreadConfigSnapshot {
|
||||
self.codex.thread_config_snapshot().await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ pub use codex_git::GhostSnapshotConfig;
|
||||
/// files are *silently truncated* to this size so we do not take up too much of
|
||||
/// the context window.
|
||||
pub(crate) const PROJECT_DOC_MAX_BYTES: usize = 32 * 1024; // 32 KiB
|
||||
pub(crate) const DEFAULT_AGENT_MAX_THREADS: Option<usize> = None;
|
||||
pub(crate) const DEFAULT_AGENT_MAX_THREADS: Option<usize> = Some(6);
|
||||
|
||||
pub const CONFIG_TOML_FILE: &str = "config.toml";
|
||||
|
||||
@@ -261,6 +261,9 @@ pub struct Config {
|
||||
/// Settings that govern if and what will be written to `~/.codex/history.jsonl`.
|
||||
pub history: History,
|
||||
|
||||
/// When true, session is not persisted on disk. Default to `false`
|
||||
pub ephemeral: bool,
|
||||
|
||||
/// Optional URI-based file opener. If set, citations to files in the model
|
||||
/// output will be hyperlinked using the specified URI scheme.
|
||||
pub file_opener: UriBasedFileOpener,
|
||||
@@ -1158,6 +1161,7 @@ pub struct ConfigOverrides {
|
||||
pub include_apply_patch_tool: Option<bool>,
|
||||
pub show_raw_agent_reasoning: Option<bool>,
|
||||
pub tools_web_search_request: Option<bool>,
|
||||
pub ephemeral: Option<bool>,
|
||||
/// Additional directories that should be treated as writable roots for this session.
|
||||
pub additional_writable_roots: Vec<PathBuf>,
|
||||
}
|
||||
@@ -1246,6 +1250,7 @@ impl Config {
|
||||
include_apply_patch_tool: include_apply_patch_tool_override,
|
||||
show_raw_agent_reasoning,
|
||||
tools_web_search_request: override_tools_web_search_request,
|
||||
ephemeral,
|
||||
additional_writable_roots,
|
||||
} = overrides;
|
||||
|
||||
@@ -1530,6 +1535,7 @@ impl Config {
|
||||
codex_home,
|
||||
config_layer_stack,
|
||||
history,
|
||||
ephemeral: ephemeral.unwrap_or_default(),
|
||||
file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode),
|
||||
codex_linux_sandbox_exe,
|
||||
|
||||
@@ -3687,10 +3693,11 @@ model_verbosity = "high"
|
||||
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
|
||||
project_doc_fallback_filenames: Vec::new(),
|
||||
tool_output_token_limit: None,
|
||||
agent_max_threads: None,
|
||||
agent_max_threads: DEFAULT_AGENT_MAX_THREADS,
|
||||
codex_home: fixture.codex_home(),
|
||||
config_layer_stack: Default::default(),
|
||||
history: History::default(),
|
||||
ephemeral: false,
|
||||
file_opener: UriBasedFileOpener::VsCode,
|
||||
codex_linux_sandbox_exe: None,
|
||||
hide_agent_reasoning: false,
|
||||
@@ -3768,10 +3775,11 @@ model_verbosity = "high"
|
||||
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
|
||||
project_doc_fallback_filenames: Vec::new(),
|
||||
tool_output_token_limit: None,
|
||||
agent_max_threads: None,
|
||||
agent_max_threads: DEFAULT_AGENT_MAX_THREADS,
|
||||
codex_home: fixture.codex_home(),
|
||||
config_layer_stack: Default::default(),
|
||||
history: History::default(),
|
||||
ephemeral: false,
|
||||
file_opener: UriBasedFileOpener::VsCode,
|
||||
codex_linux_sandbox_exe: None,
|
||||
hide_agent_reasoning: false,
|
||||
@@ -3864,10 +3872,11 @@ model_verbosity = "high"
|
||||
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
|
||||
project_doc_fallback_filenames: Vec::new(),
|
||||
tool_output_token_limit: None,
|
||||
agent_max_threads: None,
|
||||
agent_max_threads: DEFAULT_AGENT_MAX_THREADS,
|
||||
codex_home: fixture.codex_home(),
|
||||
config_layer_stack: Default::default(),
|
||||
history: History::default(),
|
||||
ephemeral: false,
|
||||
file_opener: UriBasedFileOpener::VsCode,
|
||||
codex_linux_sandbox_exe: None,
|
||||
hide_agent_reasoning: false,
|
||||
@@ -3946,10 +3955,11 @@ model_verbosity = "high"
|
||||
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
|
||||
project_doc_fallback_filenames: Vec::new(),
|
||||
tool_output_token_limit: None,
|
||||
agent_max_threads: None,
|
||||
agent_max_threads: DEFAULT_AGENT_MAX_THREADS,
|
||||
codex_home: fixture.codex_home(),
|
||||
config_layer_stack: Default::default(),
|
||||
history: History::default(),
|
||||
ephemeral: false,
|
||||
file_opener: UriBasedFileOpener::VsCode,
|
||||
codex_linux_sandbox_exe: None,
|
||||
hide_agent_reasoning: false,
|
||||
|
||||
@@ -434,11 +434,7 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
FeatureSpec {
|
||||
id: Feature::Collab,
|
||||
key: "collab",
|
||||
stage: Stage::Experimental {
|
||||
name: "Multi-agents",
|
||||
menu_description: "Allow Codex to spawn and collaborate with other agents on request (formerly named `collab`).",
|
||||
announcement: "NEW! Codex can now spawn other agents and work with them to solve your problems. Enable in /experimental!",
|
||||
},
|
||||
stage: Stage::Beta,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
|
||||
@@ -15,6 +15,7 @@ pub mod codex;
|
||||
mod codex_thread;
|
||||
mod compact_remote;
|
||||
pub use codex_thread::CodexThread;
|
||||
pub use codex_thread::ThreadConfigSnapshot;
|
||||
mod agent;
|
||||
mod codex_delegate;
|
||||
mod command_safety;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::config_types::CollaborationModeMask;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::config_types::Settings;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
|
||||
const COLLABORATION_MODE_PLAN: &str = include_str!("../../templates/collaboration_mode/plan.md");
|
||||
@@ -10,7 +9,7 @@ const COLLABORATION_MODE_PAIR_PROGRAMMING: &str =
|
||||
const COLLABORATION_MODE_EXECUTE: &str =
|
||||
include_str!("../../templates/collaboration_mode/execute.md");
|
||||
|
||||
pub(super) fn builtin_collaboration_mode_presets() -> Vec<CollaborationMode> {
|
||||
pub(super) fn builtin_collaboration_mode_presets() -> Vec<CollaborationModeMask> {
|
||||
vec![
|
||||
plan_preset(),
|
||||
code_preset(),
|
||||
@@ -20,50 +19,46 @@ pub(super) fn builtin_collaboration_mode_presets() -> Vec<CollaborationMode> {
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn test_builtin_collaboration_mode_presets() -> Vec<CollaborationMode> {
|
||||
pub fn test_builtin_collaboration_mode_presets() -> Vec<CollaborationModeMask> {
|
||||
builtin_collaboration_mode_presets()
|
||||
}
|
||||
|
||||
fn plan_preset() -> CollaborationMode {
|
||||
CollaborationMode {
|
||||
mode: ModeKind::Plan,
|
||||
settings: Settings {
|
||||
model: "gpt-5.2-codex".to_string(),
|
||||
reasoning_effort: Some(ReasoningEffort::High),
|
||||
developer_instructions: Some(COLLABORATION_MODE_PLAN.to_string()),
|
||||
},
|
||||
fn plan_preset() -> CollaborationModeMask {
|
||||
CollaborationModeMask {
|
||||
name: "Plan".to_string(),
|
||||
mode: Some(ModeKind::Plan),
|
||||
model: None,
|
||||
reasoning_effort: Some(Some(ReasoningEffort::High)),
|
||||
developer_instructions: Some(Some(COLLABORATION_MODE_PLAN.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
fn code_preset() -> CollaborationMode {
|
||||
CollaborationMode {
|
||||
mode: ModeKind::Code,
|
||||
settings: Settings {
|
||||
model: "gpt-5.2-codex".to_string(),
|
||||
reasoning_effort: Some(ReasoningEffort::Medium),
|
||||
developer_instructions: Some(COLLABORATION_MODE_CODE.to_string()),
|
||||
},
|
||||
fn code_preset() -> CollaborationModeMask {
|
||||
CollaborationModeMask {
|
||||
name: "Code".to_string(),
|
||||
mode: Some(ModeKind::Code),
|
||||
model: None,
|
||||
reasoning_effort: None,
|
||||
developer_instructions: Some(Some(COLLABORATION_MODE_CODE.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
fn pair_programming_preset() -> CollaborationMode {
|
||||
CollaborationMode {
|
||||
mode: ModeKind::PairProgramming,
|
||||
settings: Settings {
|
||||
model: "gpt-5.2-codex".to_string(),
|
||||
reasoning_effort: Some(ReasoningEffort::Medium),
|
||||
developer_instructions: Some(COLLABORATION_MODE_PAIR_PROGRAMMING.to_string()),
|
||||
},
|
||||
fn pair_programming_preset() -> CollaborationModeMask {
|
||||
CollaborationModeMask {
|
||||
name: "Pair Programming".to_string(),
|
||||
mode: Some(ModeKind::PairProgramming),
|
||||
model: None,
|
||||
reasoning_effort: Some(Some(ReasoningEffort::Medium)),
|
||||
developer_instructions: Some(Some(COLLABORATION_MODE_PAIR_PROGRAMMING.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_preset() -> CollaborationMode {
|
||||
CollaborationMode {
|
||||
mode: ModeKind::Execute,
|
||||
settings: Settings {
|
||||
model: "gpt-5.2-codex".to_string(),
|
||||
reasoning_effort: Some(ReasoningEffort::High),
|
||||
developer_instructions: Some(COLLABORATION_MODE_EXECUTE.to_string()),
|
||||
},
|
||||
fn execute_preset() -> CollaborationModeMask {
|
||||
CollaborationModeMask {
|
||||
name: "Execute".to_string(),
|
||||
mode: Some(ModeKind::Execute),
|
||||
model: None,
|
||||
reasoning_effort: Some(Some(ReasoningEffort::High)),
|
||||
developer_instructions: Some(Some(COLLABORATION_MODE_EXECUTE.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ use crate::models_manager::model_presets::builtin_model_presets;
|
||||
use codex_api::ModelsClient;
|
||||
use codex_api::ReqwestTransport;
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::config_types::CollaborationModeMask;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::openai_models::ModelPreset;
|
||||
use codex_protocol::openai_models::ModelsResponse;
|
||||
@@ -91,7 +91,7 @@ impl ModelsManager {
|
||||
/// List collaboration mode presets.
|
||||
///
|
||||
/// Returns a static set of presets seeded with the configured model.
|
||||
pub fn list_collaboration_modes(&self) -> Vec<CollaborationMode> {
|
||||
pub fn list_collaboration_modes(&self) -> Vec<CollaborationModeMask> {
|
||||
builtin_collaboration_mode_presets()
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ use crate::rollout::RolloutRecorder;
|
||||
use crate::rollout::truncation;
|
||||
use crate::skills::SkillsManager;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::config_types::CollaborationModeMask;
|
||||
use codex_protocol::openai_models::ModelPreset;
|
||||
use codex_protocol::protocol::InitialHistory;
|
||||
use codex_protocol::protocol::McpServerRefreshConfig;
|
||||
@@ -158,7 +158,7 @@ impl ThreadManager {
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn list_collaboration_modes(&self) -> Vec<CollaborationMode> {
|
||||
pub fn list_collaboration_modes(&self) -> Vec<CollaborationModeMask> {
|
||||
self.state.models_manager.list_collaboration_modes()
|
||||
}
|
||||
|
||||
@@ -314,11 +314,22 @@ impl ThreadManagerState {
|
||||
config: Config,
|
||||
agent_control: AgentControl,
|
||||
) -> CodexResult<NewThread> {
|
||||
self.spawn_thread(
|
||||
self.spawn_new_thread_with_source(config, agent_control, self.session_source.clone())
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn spawn_new_thread_with_source(
|
||||
&self,
|
||||
config: Config,
|
||||
agent_control: AgentControl,
|
||||
session_source: SessionSource,
|
||||
) -> CodexResult<NewThread> {
|
||||
self.spawn_thread_with_source(
|
||||
config,
|
||||
InitialHistory::New,
|
||||
Arc::clone(&self.auth_manager),
|
||||
agent_control,
|
||||
session_source,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -330,6 +341,24 @@ impl ThreadManagerState {
|
||||
initial_history: InitialHistory,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
agent_control: AgentControl,
|
||||
) -> CodexResult<NewThread> {
|
||||
self.spawn_thread_with_source(
|
||||
config,
|
||||
initial_history,
|
||||
auth_manager,
|
||||
agent_control,
|
||||
self.session_source.clone(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn spawn_thread_with_source(
|
||||
&self,
|
||||
config: Config,
|
||||
initial_history: InitialHistory,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
agent_control: AgentControl,
|
||||
session_source: SessionSource,
|
||||
) -> CodexResult<NewThread> {
|
||||
let CodexSpawnOk {
|
||||
codex, thread_id, ..
|
||||
@@ -339,7 +368,7 @@ impl ThreadManagerState {
|
||||
Arc::clone(&self.models_manager),
|
||||
Arc::clone(&self.skills_manager),
|
||||
initial_history,
|
||||
self.session_source.clone(),
|
||||
session_source,
|
||||
agent_control,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -78,6 +78,11 @@ impl ToolHandler for CollabHandler {
|
||||
mod spawn {
|
||||
use super::*;
|
||||
use crate::agent::AgentRole;
|
||||
use crate::agent::MAX_THREAD_SPAWN_DEPTH;
|
||||
use crate::agent::exceeds_thread_spawn_depth_limit;
|
||||
use crate::agent::next_thread_spawn_depth;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -105,6 +110,13 @@ mod spawn {
|
||||
"Empty message can't be sent to an agent".to_string(),
|
||||
));
|
||||
}
|
||||
let session_source = turn.client.get_session_source();
|
||||
let child_depth = next_thread_spawn_depth(&session_source);
|
||||
if exceeds_thread_spawn_depth_limit(child_depth) {
|
||||
return Err(FunctionCallError::RespondToModel(format!(
|
||||
"agent depth limit reached: max depth is {MAX_THREAD_SPAWN_DEPTH}"
|
||||
)));
|
||||
}
|
||||
session
|
||||
.send_event(
|
||||
&turn,
|
||||
@@ -125,7 +137,14 @@ mod spawn {
|
||||
let result = session
|
||||
.services
|
||||
.agent_control
|
||||
.spawn_agent(config, prompt.clone())
|
||||
.spawn_agent(
|
||||
config,
|
||||
prompt.clone(),
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: session.conversation_id,
|
||||
depth: child_depth,
|
||||
})),
|
||||
)
|
||||
.await
|
||||
.map_err(collab_spawn_error);
|
||||
let (new_thread_id, status) = match &result {
|
||||
@@ -573,7 +592,6 @@ fn build_agent_spawn_config(
|
||||
config.model_reasoning_summary = turn.client.get_reasoning_summary();
|
||||
config.developer_instructions = turn.developer_instructions.clone();
|
||||
config.compact_prompt = turn.compact_prompt.clone();
|
||||
config.user_instructions = turn.user_instructions.clone();
|
||||
config.shell_environment_policy = turn.shell_environment_policy.clone();
|
||||
config.codex_linux_sandbox_exe = turn.codex_linux_sandbox_exe.clone();
|
||||
config.cwd = turn.cwd.clone();
|
||||
@@ -597,13 +615,17 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::CodexAuth;
|
||||
use crate::ThreadManager;
|
||||
use crate::agent::MAX_THREAD_SPAWN_DEPTH;
|
||||
use crate::built_in_model_providers;
|
||||
use crate::client::ModelClient;
|
||||
use crate::codex::make_session_and_context;
|
||||
use crate::config::types::ShellEnvironmentPolicy;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::protocol::AskForApproval;
|
||||
use crate::protocol::Op;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::protocol::SessionSource;
|
||||
use crate::protocol::SubAgentSource;
|
||||
use crate::turn_diff_tracker::TurnDiffTracker;
|
||||
use codex_protocol::ThreadId;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -723,6 +745,45 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_agent_rejects_when_depth_limit_exceeded() {
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
session.services.agent_control = manager.agent_control();
|
||||
|
||||
let session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: session.conversation_id,
|
||||
depth: MAX_THREAD_SPAWN_DEPTH,
|
||||
});
|
||||
turn.client = ModelClient::new(
|
||||
turn.client.config(),
|
||||
Some(session.services.auth_manager.clone()),
|
||||
turn.client.get_model_info(),
|
||||
turn.client.get_otel_manager(),
|
||||
turn.client.get_provider(),
|
||||
turn.client.get_reasoning_effort(),
|
||||
turn.client.get_reasoning_summary(),
|
||||
session.conversation_id,
|
||||
session_source,
|
||||
);
|
||||
|
||||
let invocation = invocation(
|
||||
Arc::new(session),
|
||||
Arc::new(turn),
|
||||
"spawn_agent",
|
||||
function_payload(json!({"message": "hello"})),
|
||||
);
|
||||
let Err(err) = CollabHandler.handle(invocation).await else {
|
||||
panic!("spawn should fail when depth limit exceeded");
|
||||
};
|
||||
assert_eq!(
|
||||
err,
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"agent depth limit reached: max depth is {MAX_THREAD_SPAWN_DEPTH}"
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_input_rejects_empty_message() {
|
||||
let (session, turn) = make_session_and_context().await;
|
||||
@@ -1073,7 +1134,6 @@ mod tests {
|
||||
};
|
||||
turn.developer_instructions = Some("dev".to_string());
|
||||
turn.compact_prompt = Some("compact".to_string());
|
||||
turn.user_instructions = Some("user".to_string());
|
||||
turn.shell_environment_policy = ShellEnvironmentPolicy {
|
||||
use_profile: true,
|
||||
..ShellEnvironmentPolicy::default()
|
||||
@@ -1093,7 +1153,6 @@ mod tests {
|
||||
expected.model_reasoning_summary = turn.client.get_reasoning_summary();
|
||||
expected.developer_instructions = turn.developer_instructions.clone();
|
||||
expected.compact_prompt = turn.compact_prompt.clone();
|
||||
expected.user_instructions = turn.user_instructions.clone();
|
||||
expected.shell_environment_policy = turn.shell_environment_policy.clone();
|
||||
expected.codex_linux_sandbox_exe = turn.codex_linux_sandbox_exe.clone();
|
||||
expected.cwd = turn.cwd.clone();
|
||||
@@ -1107,4 +1166,31 @@ mod tests {
|
||||
.expect("sandbox policy set");
|
||||
assert_eq!(config, expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_agent_spawn_config_preserves_base_user_instructions() {
|
||||
let (session, mut turn) = make_session_and_context().await;
|
||||
let session_source = turn.client.get_session_source();
|
||||
let mut base_config = (*turn.client.config()).clone();
|
||||
base_config.user_instructions = Some("base-user".to_string());
|
||||
turn.user_instructions = Some("resolved-user".to_string());
|
||||
turn.client = ModelClient::new(
|
||||
Arc::new(base_config.clone()),
|
||||
Some(session.services.auth_manager.clone()),
|
||||
turn.client.get_model_info(),
|
||||
turn.client.get_otel_manager(),
|
||||
turn.client.get_provider(),
|
||||
turn.client.get_reasoning_effort(),
|
||||
turn.client.get_reasoning_summary(),
|
||||
session.conversation_id,
|
||||
session_source,
|
||||
);
|
||||
let base_instructions = BaseInstructions {
|
||||
text: "base".to_string(),
|
||||
};
|
||||
|
||||
let config = build_agent_spawn_config(&base_instructions, &turn).expect("spawn config");
|
||||
|
||||
assert_eq!(config.user_instructions, base_config.user_instructions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,3 @@ This feature must be used wisely. For simple or straightforward tasks, you don't
|
||||
* When you're done with a sub-agent, don't forget to close it using `close_agent`.
|
||||
* Be careful on the `timeout_ms` parameter you choose for `wait`. It should be wisely scaled.
|
||||
* Sub-agents have access to the same set of tools as you do so you must tell them if they are allowed to spawn sub-agents themselves or not.
|
||||
|
||||
## Global
|
||||
You're name is Batman
|
||||
|
||||
@@ -1,283 +1,41 @@
|
||||
# Collaboration Style: Plan
|
||||
# Plan Mode (Conversational)
|
||||
|
||||
You work in **two phases**:
|
||||
You work in 2 phases and you should *chat your way* to a great plan before finalizing it.
|
||||
|
||||
- **PHASE 1 — Understand user intent**: Align on what the user is trying to accomplish and what “success” means. Focus on intent, scope, constraints, and preference tradeoffs.
|
||||
- **PHASE 2 — Technical spec & implementation plan**: Convert the intent into a decision‑complete technical spec and an implementation plan detailed enough that another agent could execute with minimal follow‑ups.
|
||||
PHASE 1 — Intent chat (what they actually want)
|
||||
- Keep asking until you can clearly state: goal + success criteria, audience, in/out of scope, constraints, current state, and the key preferences/tradeoffs.
|
||||
- Bias toward questions over guessing: if any high‑impact ambiguity remains, do NOT plan yet—ask.
|
||||
- Include a “Confirm my understanding” question in each round (so the user can correct you early).
|
||||
|
||||
---
|
||||
PHASE 2 — Implementation chat (what/how we’ll build)
|
||||
- Once intent is stable, keep asking until the spec is decision‑complete: approach, interfaces (APIs/schemas/I/O), data flow, edge cases/failure modes, testing + acceptance criteria, rollout/monitoring, and any migrations/compat constraints.
|
||||
|
||||
## Hard interaction rule (critical)
|
||||
|
||||
Every assistant turn MUST be **exactly one** of:
|
||||
|
||||
**A) A `request_user_input` tool call** (to gather requirements and iterate), OR
|
||||
**B) The final plan output** (**plan‑only**, with a good title).
|
||||
|
||||
Constraints:
|
||||
- **Do NOT ask questions in free text.** All questions MUST be asked via `request_user_input`.
|
||||
- **Do NOT mix** a `request_user_input` call with plan content in the same turn.
|
||||
- You may use internal tools to explore (repo search, file reading, environment inspection) **before** emitting either A or B, but the user‑visible output must still be exactly A or B.
|
||||
|
||||
---
|
||||
|
||||
## Two types of uncertainty (treat differently)
|
||||
|
||||
### Type 1 — Discoverable facts (repo/system truth)
|
||||
Examples: “Where is app‑server 2 defined?”, “Which config sets turn duration?”, “Which service emits this metric?”
|
||||
|
||||
Rule: **Evidence-first exploration applies.** Don’t ask the user until you’ve searched.
|
||||
|
||||
### Type 2 — Preferences & tradeoffs (product and engineering intent)
|
||||
|
||||
Rule: **Ask early** These are often *not discoverable* and should not be silently assumed when multiple approaches are viable.
|
||||
|
||||
---
|
||||
|
||||
## Evidence‑first exploration (precondition to asking discoverable questions)
|
||||
|
||||
When a repo / codebase / workspace is available (or implied), you MUST attempt to resolve discoverable questions by **exploring first**.
|
||||
|
||||
Before calling `request_user_input` for a discoverable fact, do a quick investigation pass:
|
||||
- Run at least **2 targeted searches** (exact match + a likely variant/synonym).
|
||||
- Check the most likely “source of truth” surfaces (service manifests, infra configs, env/config files, entrypoints, schemas/types/constants).
|
||||
|
||||
You may ask the user ONLY if, after exploration:
|
||||
- There are **multiple plausible candidates** and picking wrong would materially change the implementation, OR
|
||||
- Nothing is found and you need a **missing identifier**, environment name, external dependency, or non-repo context, OR
|
||||
- The repo reveals ambiguity that must be resolved by product intent (not code).
|
||||
|
||||
If you found a **single best match**, DO NOT ask the user — proceed and record it as an assumption in the final plan.
|
||||
|
||||
If you must ask, incorporate what you already found:
|
||||
- Provide **options listing the candidates** you discovered (paths/service names), with a **recommended** option.
|
||||
- Do NOT ask the user to “point to the path” unless you have **zero candidates** after searching.
|
||||
|
||||
---
|
||||
|
||||
## Preference capture (you SHOULD ask when it changes the plan)
|
||||
|
||||
If there are **multiple reasonable implementation approaches** with meaningful tradeoffs, you SHOULD ask the user to choose their preference even if you could assume a default.
|
||||
|
||||
Treat tradeoff choice as **high-impact** unless the user explicitly said:
|
||||
- “Use your best judgement,” or
|
||||
- “Pick whatever is simplest,” or
|
||||
- “I don’t care—ship fast.”
|
||||
|
||||
When asking a preference question:
|
||||
- Provide **2–4 mutually exclusive options**.
|
||||
- Include a **recommended default** that matches the user’s apparent goals.
|
||||
- If the user doesn’t answer, proceed with the recommended option and record it as an assumption.
|
||||
|
||||
---
|
||||
|
||||
## No‑trivia rule for questions (guardrail)
|
||||
|
||||
You MUST NOT ask questions whose answers are likely to be found by:
|
||||
- repo text search,
|
||||
- reading config/infra manifests,
|
||||
- following imports/types/constants,
|
||||
unless you already attempted those and can summarize what you found.
|
||||
|
||||
Every `request_user_input` question must:
|
||||
- materially change an implementation decision, OR
|
||||
- disambiguate between **concrete candidates** you already found, OR
|
||||
- capture a **preference/tradeoff** that is not discoverable from the repo.
|
||||
|
||||
---
|
||||
|
||||
## PHASE 1 — Understand user intent
|
||||
|
||||
### Purpose
|
||||
Identify what the user actually wants, what matters most, and what constraints + preferences shape the solution.
|
||||
|
||||
### Phase 1 principles
|
||||
- State what you think the user cares about (speed vs quality, prototype vs production, etc.).
|
||||
- Think out loud briefly when it helps weigh tradeoffs.
|
||||
- Use reasonable suggestions with explicit assumptions; make it easy to accept/override.
|
||||
- Ask fewer, better questions. Ask only what materially changes the spec/plan OR captures a real tradeoff.
|
||||
- Think ahead: propose helpful suggestions the user may need (testing, debug mode, observability, migration path).
|
||||
|
||||
### Phase 1 exit criteria (Intent gate)
|
||||
Before moving to Phase 2, ensure you have either a **user answer** OR an **explicit assumption** for:
|
||||
|
||||
**Intent basics**
|
||||
- Primary goal + success criteria (how we know it worked)
|
||||
- Primary user / audience
|
||||
- In-scope and out-of-scope
|
||||
- Constraints (time, budget, platform, security/compliance)
|
||||
- Current context (what exists today: code/system/data)
|
||||
|
||||
**Preference profile (don’t silently assume if unclear and high-impact)**
|
||||
- Risk posture: prototype vs production quality bar
|
||||
- Tradeoff priority: ship fast vs robust/maintainable
|
||||
- Compatibility expectations: backward compatibility / migrations / downtime tolerance (if relevant)
|
||||
|
||||
Use `request_user_input` to deeply understand the user's intent after exploring your environment.
|
||||
|
||||
---
|
||||
|
||||
## PHASE 2 — Technical spec & implementation plan
|
||||
|
||||
### Purpose
|
||||
Turn the intent into a buildable, decision-complete technical spec.
|
||||
|
||||
### Phase 2 exit criteria (Spec gate)
|
||||
Before finalizing the plan, ensure you’ve pinned down (answer or assumption):
|
||||
- Chosen approach + 1–2 alternatives with tradeoffs
|
||||
- Interfaces (APIs, schemas, inputs/outputs)
|
||||
- Data flow + key edge cases / failure modes
|
||||
- Testing + acceptance criteria
|
||||
- Rollout/monitoring expectations
|
||||
- Any key preference/tradeoff decisions (and rationale)
|
||||
|
||||
If something is high-impact and unknown, ask via `request_user_input`. Otherwise assume defaults and proceed.
|
||||
|
||||
---
|
||||
|
||||
## Using `request_user_input` in Plan Mode
|
||||
|
||||
Use `request_user_input` when either:
|
||||
1) You are genuinely blocked on a decision that materially changes the plan and cannot be resolved via evidence-first exploration, OR
|
||||
2) There is a meaningful **preference/tradeoff** the user should choose among.
|
||||
3) When an answer is skipped, assume the recommended path.
|
||||
|
||||
Every assistant turn MUST be exactly one of:
|
||||
A) a `request_user_input` tool call (questions/options only), OR
|
||||
B) the final output: a titled, plan‑only document.
|
||||
Rules:
|
||||
- **Default to options** when there are ≤ 4 common outcomes; include a **recommended** option.
|
||||
- Use **free-form only** when truly unbounded (e.g., “paste schema”, “share constraints”, “provide examples”).
|
||||
- Every question must be tied to a decision that changes the spec (A→X, B→Y).
|
||||
- If you found candidates in the repo, options MUST reference them (paths/service names) so the user chooses among concrete items.
|
||||
- No questions in free text (only via `request_user_input`).
|
||||
- Never mix a `request_user_input` call with plan content.
|
||||
- Internal tool/repo exploration is allowed privately before A or B.
|
||||
|
||||
Do **not** use `request_user_input` to ask:
|
||||
- “is my plan ready?” / “should I proceed?”
|
||||
- “where is X?” when repo search can answer it.
|
||||
## Ask a lot, but never ask trivia
|
||||
You SHOULD ask many questions, but each question must:
|
||||
- materially change the spec/plan, OR
|
||||
- confirm/lock an assumption, OR
|
||||
- choose between meaningful tradeoffs.
|
||||
Batch questions (e.g., 4–10) per `request_user_input` call to keep momentum.
|
||||
|
||||
(If your environment enforces a limit, aim to resolve within ~5 `request_user_input` calls; if still blocked, ask only the most decision-critical remaining question(s) and proceed with explicit assumptions.)
|
||||
## Two kinds of unknowns (treat differently)
|
||||
1) Discoverable facts (repo/system truth): explore first.
|
||||
- Before asking, run ≥2 targeted searches (exact + variant) and check likely sources of truth (configs/manifests/entrypoints/schemas/types/constants).
|
||||
- Ask only if: multiple plausible candidates; nothing found but you need a missing identifier/context; or ambiguity is actually product intent.
|
||||
- If asking, present concrete candidates (paths/service names) + recommend one.
|
||||
|
||||
### Examples (technical, schema-populated)
|
||||
2) Preferences/tradeoffs (not discoverable): ask early.
|
||||
- Provide 2–4 mutually exclusive options + a recommended default.
|
||||
- If unanswered, proceed with the recommended option and record it as an assumption in the final plan.
|
||||
|
||||
**1) Boolean (yes/no), no free-form**
|
||||
```json
|
||||
{
|
||||
"questions": [
|
||||
{
|
||||
"id": "enable_migration",
|
||||
"header": "Migrate",
|
||||
"question": "Enable the database migration in this release?",
|
||||
"options": [
|
||||
{ "label": "Yes (Recommended)", "description": "Ship the migration with this rollout." },
|
||||
{ "label": "No", "description": "Defer the migration to a later release." }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
````
|
||||
|
||||
**2) Preference/tradeoff question (recommended + options)**
|
||||
|
||||
```json
|
||||
{
|
||||
"questions": [
|
||||
{
|
||||
"id": "tradeoff_priority",
|
||||
"header": "Tradeoff",
|
||||
"question": "Which priority should guide the implementation?",
|
||||
"options": [
|
||||
{ "label": "Ship fast (Recommended)", "description": "Minimal changes, pragmatic shortcuts, faster delivery." },
|
||||
{ "label": "Robust & maintainable", "description": "Cleaner abstractions, more refactor, better long-term stability." },
|
||||
{ "label": "Performance-first", "description": "Optimize latency/throughput even if complexity rises." },
|
||||
{ "label": "Other", "description": "Specify a different priority or constraint." }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**3) Free-form only (no options)**
|
||||
|
||||
```json
|
||||
{
|
||||
"questions": [
|
||||
{
|
||||
"id": "acceptance_criteria",
|
||||
"header": "Success",
|
||||
"question": "What are the acceptance criteria or success metrics we should optimize for?"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Iterating and final output
|
||||
|
||||
Only AFTER you have all the information (or explicit assumptions for remaining low-impact unknowns), write the full plan.
|
||||
|
||||
A good plan here is **decision-complete**: it contains the concrete choices, interfaces, acceptance criteria, and rollout details needed for another agent to execute with minimal back-and-forth.
|
||||
|
||||
### Plan output (what to include)
|
||||
|
||||
Your plan MUST include the sections below. Keep them concise but specific; include only what’s relevant to the task.
|
||||
|
||||
1. **Title**
|
||||
|
||||
* A clear, specific title describing what will be built/delivered.
|
||||
|
||||
2. **Goal & Success Criteria**
|
||||
|
||||
* What outcome we’re driving.
|
||||
* Concrete acceptance criteria (tests, metrics, or observable behavior). Prefer “done when …”.
|
||||
|
||||
3. **Non-goals / Out of Scope**
|
||||
|
||||
* Explicit boundaries to prevent scope creep.
|
||||
|
||||
4. **Assumptions**
|
||||
|
||||
* Any defaults you assumed due to missing info, labeled clearly.
|
||||
|
||||
5. **Proposed Solution**
|
||||
|
||||
* The chosen approach (with rationale).
|
||||
* 1–2 alternatives considered and why they were not chosen (brief tradeoffs).
|
||||
|
||||
6. **System Design**
|
||||
|
||||
* Architecture / components / data flow (only as deep as needed).
|
||||
* Key invariants, edge cases, and failure modes (and how they’re handled).
|
||||
|
||||
7. **Interfaces & Data Contracts**
|
||||
|
||||
* APIs, schemas, inputs/outputs, event formats, config flags, etc.
|
||||
* Validation rules and backward/forward compatibility expectations if applicable.
|
||||
|
||||
8. **Execution Details**
|
||||
|
||||
* Concrete implementation steps and ordering.
|
||||
* **Codebase specifics are conditional**: include file/module/function names, directories, migrations, and dependencies **only when relevant and known** (or when you can reasonably infer them).
|
||||
* If unknown, specify what to discover and how (e.g., “search for X symbol”, “locate Y service entrypoint”).
|
||||
|
||||
9. **Testing & Quality**
|
||||
|
||||
* Test strategy (unit/integration/e2e) proportional to risk.
|
||||
* How to verify locally and in staging; include any test data or harness needs.
|
||||
|
||||
10. **Rollout, Observability, and Ops**
|
||||
|
||||
* Release strategy (flags, gradual rollout, migration plan).
|
||||
* Monitoring/alerts/logging and dashboards to add or update.
|
||||
* Rollback strategy and operational playbook notes (brief).
|
||||
|
||||
11. **Risks & Mitigations**
|
||||
|
||||
* Top risks (technical, product, security, privacy, performance).
|
||||
* Specific mitigations and “watch-outs”.
|
||||
|
||||
12. **Open Questions**
|
||||
|
||||
* Only if something truly must be resolved later; include how to resolve and what decision it affects.
|
||||
|
||||
### Plan output (strict)
|
||||
|
||||
**The final output should contain the plan and plan only with a good title.**
|
||||
PLEASE DO NOT confirm the plan with the user before ending. The user will be responsible for telling us to update, iterate or execute the plan.
|
||||
## Finalization rule
|
||||
Only output the final plan when remaining unknowns are low‑impact and explicitly listed as assumptions.
|
||||
Final output must be plan‑only with a good title (no “should I proceed?”).
|
||||
@@ -422,7 +422,11 @@ async fn resume_replays_collaboration_instructions() -> Result<()> {
|
||||
|
||||
let mut builder = test_codex();
|
||||
let initial = builder.build(&server).await?;
|
||||
let rollout_path = initial.session_configured.rollout_path.clone();
|
||||
let rollout_path = initial
|
||||
.session_configured
|
||||
.rollout_path
|
||||
.clone()
|
||||
.expect("rollout path");
|
||||
let home = initial.home.clone();
|
||||
|
||||
let collab_text = "resume instructions";
|
||||
|
||||
@@ -154,7 +154,7 @@ async fn summarize_context_three_requests_and_instructions() {
|
||||
session_configured,
|
||||
..
|
||||
} = thread_manager.start_thread(config).await.unwrap();
|
||||
let rollout_path = session_configured.rollout_path;
|
||||
let rollout_path = session_configured.rollout_path.expect("rollout path");
|
||||
|
||||
// 1) Normal user input – should hit server once.
|
||||
codex
|
||||
@@ -1237,7 +1237,11 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() {
|
||||
});
|
||||
let initial = builder.build(&server).await.unwrap();
|
||||
let home = initial.home.clone();
|
||||
let rollout_path = initial.session_configured.rollout_path.clone();
|
||||
let rollout_path = initial
|
||||
.session_configured
|
||||
.rollout_path
|
||||
.clone()
|
||||
.expect("rollout path");
|
||||
|
||||
// A single over-limit completion should not auto-compact until the next user message.
|
||||
mount_sse_once(
|
||||
@@ -1429,7 +1433,7 @@ async fn auto_compact_persists_rollout_entries() {
|
||||
codex.submit(Op::Shutdown).await.unwrap();
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await;
|
||||
|
||||
let rollout_path = session_configured.rollout_path;
|
||||
let rollout_path = session_configured.rollout_path.expect("rollout path");
|
||||
let text = std::fs::read_to_string(&rollout_path).unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"failed to read rollout file {}: {e}",
|
||||
|
||||
@@ -230,7 +230,12 @@ async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()>
|
||||
)
|
||||
.await?;
|
||||
let codex = harness.test().codex.clone();
|
||||
let rollout_path = harness.test().session_configured.rollout_path.clone();
|
||||
let rollout_path = harness
|
||||
.test()
|
||||
.session_configured
|
||||
.rollout_path
|
||||
.clone()
|
||||
.expect("rollout path");
|
||||
|
||||
let responses_mock = responses::mount_sse_once(
|
||||
harness.server(),
|
||||
|
||||
@@ -1013,7 +1013,7 @@ async fn compact_conversation(conversation: &Arc<CodexThread>) {
|
||||
}
|
||||
|
||||
async fn fetch_conversation_path(conversation: &Arc<CodexThread>) -> std::path::PathBuf {
|
||||
conversation.rollout_path()
|
||||
conversation.rollout_path().expect("rollout path")
|
||||
}
|
||||
|
||||
async fn resume_conversation(
|
||||
|
||||
@@ -80,7 +80,7 @@ async fn fork_thread_twice_drops_to_first_message() {
|
||||
}
|
||||
|
||||
// Request history from the base conversation to obtain rollout path.
|
||||
let base_path = codex.rollout_path();
|
||||
let base_path = codex.rollout_path().expect("rollout path");
|
||||
|
||||
// GetHistory flushes before returning the path; no wait needed.
|
||||
|
||||
@@ -135,7 +135,7 @@ async fn fork_thread_twice_drops_to_first_message() {
|
||||
.await
|
||||
.expect("fork 1");
|
||||
|
||||
let fork1_path = codex_fork1.rollout_path();
|
||||
let fork1_path = codex_fork1.rollout_path().expect("rollout path");
|
||||
|
||||
// GetHistory on fork1 flushed; the file is ready.
|
||||
let fork1_items = read_items(&fork1_path);
|
||||
@@ -154,7 +154,7 @@ async fn fork_thread_twice_drops_to_first_message() {
|
||||
.await
|
||||
.expect("fork 2");
|
||||
|
||||
let fork2_path = codex_fork2.rollout_path();
|
||||
let fork2_path = codex_fork2.rollout_path().expect("rollout path");
|
||||
// GetHistory on fork2 flushed; the file is ready.
|
||||
let fork1_items = read_items(&fork1_path);
|
||||
let fork1_user_inputs = find_user_input_positions(&fork1_items);
|
||||
|
||||
@@ -136,7 +136,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu
|
||||
codex.submit(Op::Shutdown).await?;
|
||||
wait_for_event(&codex, |event| matches!(event, EventMsg::ShutdownComplete)).await;
|
||||
|
||||
let rollout_path = codex.rollout_path();
|
||||
let rollout_path = codex.rollout_path().expect("rollout path");
|
||||
let rollout_text = read_rollout_text(&rollout_path).await?;
|
||||
let actual = find_user_message_with_image(&rollout_text)
|
||||
.expect("expected user message with input image in rollout");
|
||||
@@ -217,7 +217,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()>
|
||||
codex.submit(Op::Shutdown).await?;
|
||||
wait_for_event(&codex, |event| matches!(event, EventMsg::ShutdownComplete)).await;
|
||||
|
||||
let rollout_path = codex.rollout_path();
|
||||
let rollout_path = codex.rollout_path().expect("rollout path");
|
||||
let rollout_text = read_rollout_text(&rollout_path).await?;
|
||||
let actual = find_user_message_with_image(&rollout_text)
|
||||
.expect("expected user message with input image in rollout");
|
||||
|
||||
@@ -129,7 +129,7 @@ async fn override_turn_context_records_permissions_update() -> Result<()> {
|
||||
test.codex.submit(Op::Shutdown).await?;
|
||||
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await;
|
||||
|
||||
let rollout_path = test.codex.rollout_path();
|
||||
let rollout_path = test.codex.rollout_path().expect("rollout path");
|
||||
let rollout_text = read_rollout_text(&rollout_path).await?;
|
||||
let developer_texts = rollout_developer_texts(&rollout_text);
|
||||
let approval_texts: Vec<&String> = developer_texts
|
||||
@@ -172,7 +172,7 @@ async fn override_turn_context_records_environment_update() -> Result<()> {
|
||||
test.codex.submit(Op::Shutdown).await?;
|
||||
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await;
|
||||
|
||||
let rollout_path = test.codex.rollout_path();
|
||||
let rollout_path = test.codex.rollout_path().expect("rollout path");
|
||||
let rollout_text = read_rollout_text(&rollout_path).await?;
|
||||
let env_texts = rollout_environment_texts(&rollout_text);
|
||||
let new_cwd_text = new_cwd.path().display().to_string();
|
||||
@@ -209,7 +209,7 @@ async fn override_turn_context_records_collaboration_update() -> Result<()> {
|
||||
test.codex.submit(Op::Shutdown).await?;
|
||||
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await;
|
||||
|
||||
let rollout_path = test.codex.rollout_path();
|
||||
let rollout_path = test.codex.rollout_path().expect("rollout path");
|
||||
let rollout_text = read_rollout_text(&rollout_path).await?;
|
||||
let developer_texts = rollout_developer_texts(&rollout_text);
|
||||
let collab_text = collab_xml(collab_text);
|
||||
|
||||
@@ -202,7 +202,11 @@ async fn resume_replays_permissions_messages() -> Result<()> {
|
||||
config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||
});
|
||||
let initial = builder.build(&server).await?;
|
||||
let rollout_path = initial.session_configured.rollout_path.clone();
|
||||
let rollout_path = initial
|
||||
.session_configured
|
||||
.rollout_path
|
||||
.clone()
|
||||
.expect("rollout path");
|
||||
let home = initial.home.clone();
|
||||
|
||||
initial
|
||||
@@ -280,7 +284,11 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> {
|
||||
config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||
});
|
||||
let initial = builder.build(&server).await?;
|
||||
let rollout_path = initial.session_configured.rollout_path.clone();
|
||||
let rollout_path = initial
|
||||
.session_configured
|
||||
.rollout_path
|
||||
.clone()
|
||||
.expect("rollout path");
|
||||
let home = initial.home.clone();
|
||||
|
||||
initial
|
||||
|
||||
@@ -26,7 +26,11 @@ async fn resume_includes_initial_messages_from_rollout_events() -> Result<()> {
|
||||
let initial = builder.build(&server).await?;
|
||||
let codex = Arc::clone(&initial.codex);
|
||||
let home = initial.home.clone();
|
||||
let rollout_path = initial.session_configured.rollout_path.clone();
|
||||
let rollout_path = initial
|
||||
.session_configured
|
||||
.rollout_path
|
||||
.clone()
|
||||
.expect("rollout path");
|
||||
|
||||
let initial_sse = sse(vec![
|
||||
ev_response_created("resp-initial"),
|
||||
@@ -85,7 +89,11 @@ async fn resume_includes_initial_messages_from_reasoning_events() -> Result<()>
|
||||
let initial = builder.build(&server).await?;
|
||||
let codex = Arc::clone(&initial.codex);
|
||||
let home = initial.home.clone();
|
||||
let rollout_path = initial.session_configured.rollout_path.clone();
|
||||
let rollout_path = initial
|
||||
.session_configured
|
||||
.rollout_path
|
||||
.clone()
|
||||
.expect("rollout path");
|
||||
|
||||
let initial_sse = sse(vec![
|
||||
ev_response_created("resp-initial"),
|
||||
@@ -143,7 +151,11 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> {
|
||||
let initial = builder.build(&server).await?;
|
||||
let codex = Arc::clone(&initial.codex);
|
||||
let home = initial.home.clone();
|
||||
let rollout_path = initial.session_configured.rollout_path.clone();
|
||||
let rollout_path = initial
|
||||
.session_configured
|
||||
.rollout_path
|
||||
.clone()
|
||||
.expect("rollout path");
|
||||
|
||||
let initial_sse = sse(vec![
|
||||
ev_response_created("resp-initial"),
|
||||
|
||||
@@ -120,7 +120,7 @@ async fn review_op_emits_lifecycle_and_review_output() {
|
||||
|
||||
// Also verify that a user message with the header and a formatted finding
|
||||
// was recorded back in the parent session's rollout.
|
||||
let path = codex.rollout_path();
|
||||
let path = codex.rollout_path().expect("rollout path");
|
||||
let text = std::fs::read_to_string(&path).expect("read rollout file");
|
||||
|
||||
let mut saw_header = false;
|
||||
@@ -627,7 +627,7 @@ async fn review_input_isolated_from_parent_history() {
|
||||
assert_eq!(instructions, REVIEW_PROMPT);
|
||||
|
||||
// Also verify that a user interruption note was recorded in the rollout.
|
||||
let path = codex.rollout_path();
|
||||
let path = codex.rollout_path().expect("rollout path");
|
||||
let text = std::fs::read_to_string(&path).expect("read rollout file");
|
||||
let mut saw_interruption_message = false;
|
||||
for line in text.lines() {
|
||||
|
||||
@@ -101,7 +101,8 @@ Each response yields:
|
||||
|
||||
Fetch the built-in collaboration mode presets with `collaborationMode/list`. This endpoint does not accept pagination and returns the full list in one response:
|
||||
|
||||
- `data` – ordered list of collaboration mode presets
|
||||
- `data` – ordered list of collaboration mode masks (partial settings to apply on top of the base mode)
|
||||
- For tri-state fields like `reasoning_effort` and `developer_instructions`, omit the field to keep the current value, set it to `null` to clear it, or set a concrete value to update it.
|
||||
|
||||
## Event stream
|
||||
|
||||
|
||||
@@ -229,6 +229,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
include_apply_patch_tool: None,
|
||||
show_raw_agent_reasoning: oss.then_some(true),
|
||||
tools_web_search_request: None,
|
||||
ephemeral: None,
|
||||
additional_writable_roots: add_dir,
|
||||
};
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ fn session_configured_produces_thread_started_event() {
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
rollout_path,
|
||||
rollout_path: Some(rollout_path),
|
||||
}),
|
||||
);
|
||||
let out = ep.collect_thread_events(&ev);
|
||||
|
||||
@@ -267,7 +267,7 @@ mod tests {
|
||||
history_log_id: 1,
|
||||
history_entry_count: 1000,
|
||||
initial_messages: None,
|
||||
rollout_path: rollout_file.path().to_path_buf(),
|
||||
rollout_path: Some(rollout_file.path().to_path_buf()),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -307,7 +307,7 @@ mod tests {
|
||||
history_log_id: 1,
|
||||
history_entry_count: 1000,
|
||||
initial_messages: None,
|
||||
rollout_path: rollout_file.path().to_path_buf(),
|
||||
rollout_path: Some(rollout_file.path().to_path_buf()),
|
||||
};
|
||||
let event = Event {
|
||||
id: "1".to_string(),
|
||||
@@ -371,7 +371,7 @@ mod tests {
|
||||
history_log_id: 1,
|
||||
history_entry_count: 1000,
|
||||
initial_messages: None,
|
||||
rollout_path: rollout_file.path().to_path_buf(),
|
||||
rollout_path: Some(rollout_file.path().to_path_buf()),
|
||||
};
|
||||
let event = Event {
|
||||
id: "1".to_string(),
|
||||
|
||||
@@ -188,21 +188,21 @@ impl CollaborationMode {
|
||||
///
|
||||
/// - `model`: `Some(s)` to update the model, `None` to keep the current model
|
||||
/// - `effort`: `Some(Some(e))` to set effort to `e`, `Some(None)` to clear effort, `None` to keep current effort
|
||||
/// - `developer_instructions`: `Some(s)` to update developer instructions, `None` to keep current
|
||||
/// - `developer_instructions`: `Some(Some(s))` to set instructions, `Some(None)` to clear them, `None` to keep current
|
||||
///
|
||||
/// Returns a new `CollaborationMode` with updated values, preserving the mode.
|
||||
pub fn with_updates(
|
||||
&self,
|
||||
model: Option<String>,
|
||||
effort: Option<Option<ReasoningEffort>>,
|
||||
developer_instructions: Option<String>,
|
||||
developer_instructions: Option<Option<String>>,
|
||||
) -> Self {
|
||||
let settings = self.settings_ref();
|
||||
let updated_settings = Settings {
|
||||
model: model.unwrap_or_else(|| settings.model.clone()),
|
||||
reasoning_effort: effort.unwrap_or(settings.reasoning_effort),
|
||||
developer_instructions: developer_instructions
|
||||
.or_else(|| settings.developer_instructions.clone()),
|
||||
.unwrap_or_else(|| settings.developer_instructions.clone()),
|
||||
};
|
||||
|
||||
CollaborationMode {
|
||||
@@ -210,6 +210,26 @@ impl CollaborationMode {
|
||||
settings: updated_settings,
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies a mask to this collaboration mode, returning a new collaboration mode
|
||||
/// with the mask values applied. Fields in the mask that are `Some` will override
|
||||
/// the corresponding fields, while `None` values will preserve the original values.
|
||||
///
|
||||
/// The `name` field in the mask is ignored as it's metadata for the mask itself.
|
||||
pub fn apply_mask(&self, mask: &CollaborationModeMask) -> Self {
|
||||
let settings = self.settings_ref();
|
||||
CollaborationMode {
|
||||
mode: mask.mode.unwrap_or(self.mode),
|
||||
settings: Settings {
|
||||
model: mask.model.clone().unwrap_or_else(|| settings.model.clone()),
|
||||
reasoning_effort: mask.reasoning_effort.unwrap_or(settings.reasoning_effort),
|
||||
developer_instructions: mask
|
||||
.developer_instructions
|
||||
.clone()
|
||||
.unwrap_or_else(|| settings.developer_instructions.clone()),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings for a collaboration mode.
|
||||
@@ -219,3 +239,49 @@ pub struct Settings {
|
||||
pub reasoning_effort: Option<ReasoningEffort>,
|
||||
pub developer_instructions: Option<String>,
|
||||
}
|
||||
|
||||
/// A mask for collaboration mode settings, allowing partial updates.
|
||||
/// All fields except `name` are optional, enabling selective updates.
|
||||
#[derive(Clone, PartialEq, Eq, Hash, Debug, Serialize, Deserialize, JsonSchema, TS)]
|
||||
pub struct CollaborationModeMask {
|
||||
pub name: String,
|
||||
pub mode: Option<ModeKind>,
|
||||
pub model: Option<String>,
|
||||
pub reasoning_effort: Option<Option<ReasoningEffort>>,
|
||||
pub developer_instructions: Option<Option<String>>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn apply_mask_can_clear_optional_fields() {
|
||||
let mode = CollaborationMode {
|
||||
mode: ModeKind::Code,
|
||||
settings: Settings {
|
||||
model: "gpt-5.2-codex".to_string(),
|
||||
reasoning_effort: Some(ReasoningEffort::High),
|
||||
developer_instructions: Some("stay focused".to_string()),
|
||||
},
|
||||
};
|
||||
let mask = CollaborationModeMask {
|
||||
name: "Clear".to_string(),
|
||||
mode: None,
|
||||
model: None,
|
||||
reasoning_effort: Some(None),
|
||||
developer_instructions: Some(None),
|
||||
};
|
||||
|
||||
let expected = CollaborationMode {
|
||||
mode: ModeKind::Code,
|
||||
settings: Settings {
|
||||
model: "gpt-5.2-codex".to_string(),
|
||||
reasoning_effort: None,
|
||||
developer_instructions: None,
|
||||
},
|
||||
};
|
||||
assert_eq!(expected, mode.apply_mask(&mask));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1518,6 +1518,10 @@ pub enum SessionSource {
|
||||
pub enum SubAgentSource {
|
||||
Review,
|
||||
Compact,
|
||||
ThreadSpawn {
|
||||
parent_thread_id: ThreadId,
|
||||
depth: i32,
|
||||
},
|
||||
Other(String),
|
||||
}
|
||||
|
||||
@@ -1539,6 +1543,12 @@ impl fmt::Display for SubAgentSource {
|
||||
match self {
|
||||
SubAgentSource::Review => f.write_str("review"),
|
||||
SubAgentSource::Compact => f.write_str("compact"),
|
||||
SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth,
|
||||
} => {
|
||||
write!(f, "thread_spawn_{parent_thread_id}_d{depth}")
|
||||
}
|
||||
SubAgentSource::Other(other) => f.write_str(other),
|
||||
}
|
||||
}
|
||||
@@ -2139,7 +2149,9 @@ pub struct SessionConfiguredEvent {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub initial_messages: Option<Vec<EventMsg>>,
|
||||
|
||||
pub rollout_path: PathBuf,
|
||||
/// Path in which the rollout is stored. Can be `None` for ephemeral threads
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// User's decision in response to an ExecApprovalRequest.
|
||||
@@ -2491,7 +2503,7 @@ mod tests {
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
rollout_path: rollout_file.path().to_path_buf(),
|
||||
rollout_path: Some(rollout_file.path().to_path_buf()),
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
+146
-10
@@ -12,6 +12,7 @@ use crate::bottom_pane::SelectionViewParams;
|
||||
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
|
||||
use crate::chatwidget::ChatWidget;
|
||||
use crate::chatwidget::ExternalEditorState;
|
||||
use crate::cwd_prompt::CwdPromptAction;
|
||||
use crate::diff_render::DiffSummary;
|
||||
use crate::exec_command::strip_bash_lc_and_escape;
|
||||
use crate::external_editor;
|
||||
@@ -36,6 +37,8 @@ use codex_core::AuthManager;
|
||||
use codex_core::CodexAuth;
|
||||
use codex_core::ThreadManager;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigBuilder;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config::edit::ConfigEdit;
|
||||
use codex_core::config::edit::ConfigEditsBuilder;
|
||||
use codex_core::config_loader::ConfigLayerStackOrdering;
|
||||
@@ -44,12 +47,14 @@ use codex_core::features::Feature;
|
||||
use codex_core::models_manager::manager::RefreshStrategy;
|
||||
use codex_core::models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG;
|
||||
use codex_core::models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::DeprecationNoticeEvent;
|
||||
use codex_core::protocol::Event;
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::FinalOutput;
|
||||
use codex_core::protocol::ListSkillsResponseEvent;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use codex_core::protocol::SessionSource;
|
||||
use codex_core::protocol::SkillErrorInfo;
|
||||
use codex_core::protocol::TokenUsage;
|
||||
@@ -60,6 +65,7 @@ use codex_protocol::openai_models::ModelPreset;
|
||||
use codex_protocol::openai_models::ModelUpgrade;
|
||||
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
|
||||
use codex_protocol::protocol::SessionConfiguredEvent;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use color_eyre::eyre::Result;
|
||||
use color_eyre::eyre::WrapErr;
|
||||
use crossterm::event::KeyCode;
|
||||
@@ -87,6 +93,7 @@ use tokio::sync::broadcast;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::mpsc::error::TryRecvError;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
use toml::Value as TomlValue;
|
||||
|
||||
const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue.";
|
||||
const THREAD_EVENT_CHANNEL_CAPACITY: usize = 1024;
|
||||
@@ -498,6 +505,10 @@ pub(crate) struct App {
|
||||
/// Config is stored here so we can recreate ChatWidgets as needed.
|
||||
pub(crate) config: Config,
|
||||
pub(crate) active_profile: Option<String>,
|
||||
cli_kv_overrides: Vec<(String, TomlValue)>,
|
||||
harness_overrides: ConfigOverrides,
|
||||
runtime_approval_policy_override: Option<AskForApproval>,
|
||||
runtime_sandbox_policy_override: Option<SandboxPolicy>,
|
||||
|
||||
pub(crate) file_search: FileSearchManager,
|
||||
|
||||
@@ -545,6 +556,23 @@ struct WindowsSandboxState {
|
||||
skip_world_writable_scan_once: bool,
|
||||
}
|
||||
|
||||
fn normalize_harness_overrides_for_cwd(
|
||||
mut overrides: ConfigOverrides,
|
||||
base_cwd: &Path,
|
||||
) -> Result<ConfigOverrides> {
|
||||
if overrides.additional_writable_roots.is_empty() {
|
||||
return Ok(overrides);
|
||||
}
|
||||
|
||||
let mut normalized = Vec::with_capacity(overrides.additional_writable_roots.len());
|
||||
for root in overrides.additional_writable_roots.drain(..) {
|
||||
let absolute = AbsolutePathBuf::resolve_path_against_base(root, base_cwd)?;
|
||||
normalized.push(absolute.into_path_buf());
|
||||
}
|
||||
overrides.additional_writable_roots = normalized;
|
||||
Ok(overrides)
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn chatwidget_init_for_forked_or_resumed_thread(
|
||||
&self,
|
||||
@@ -567,6 +595,38 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
async fn rebuild_config_for_cwd(&self, cwd: PathBuf) -> Result<Config> {
|
||||
let mut overrides = self.harness_overrides.clone();
|
||||
overrides.cwd = Some(cwd.clone());
|
||||
let cwd_display = cwd.display().to_string();
|
||||
ConfigBuilder::default()
|
||||
.codex_home(self.config.codex_home.clone())
|
||||
.cli_overrides(self.cli_kv_overrides.clone())
|
||||
.harness_overrides(overrides)
|
||||
.build()
|
||||
.await
|
||||
.wrap_err_with(|| format!("Failed to rebuild config for cwd {cwd_display}"))
|
||||
}
|
||||
|
||||
fn apply_runtime_policy_overrides(&mut self, config: &mut Config) {
|
||||
if let Some(policy) = self.runtime_approval_policy_override.as_ref()
|
||||
&& let Err(err) = config.approval_policy.set(*policy)
|
||||
{
|
||||
tracing::warn!(%err, "failed to carry forward approval policy override");
|
||||
self.chat_widget.add_error_message(format!(
|
||||
"Failed to carry forward approval policy override: {err}"
|
||||
));
|
||||
}
|
||||
if let Some(policy) = self.runtime_sandbox_policy_override.as_ref()
|
||||
&& let Err(err) = config.sandbox_policy.set(policy.clone())
|
||||
{
|
||||
tracing::warn!(%err, "failed to carry forward sandbox policy override");
|
||||
self.chat_widget.add_error_message(format!(
|
||||
"Failed to carry forward sandbox policy override: {err}"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
async fn shutdown_current_thread(&mut self) {
|
||||
if let Some(thread_id) = self.chat_widget.thread_id() {
|
||||
// Clear any in-flight rollback guard when switching threads.
|
||||
@@ -824,6 +884,8 @@ impl App {
|
||||
tui: &mut tui::Tui,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
mut config: Config,
|
||||
cli_kv_overrides: Vec<(String, TomlValue)>,
|
||||
harness_overrides: ConfigOverrides,
|
||||
active_profile: Option<String>,
|
||||
initial_prompt: Option<String>,
|
||||
initial_images: Vec<PathBuf>,
|
||||
@@ -838,6 +900,8 @@ impl App {
|
||||
emit_deprecation_notice(&app_event_tx, ollama_chat_support_notice);
|
||||
emit_project_config_warnings(&app_event_tx, &config);
|
||||
|
||||
let harness_overrides =
|
||||
normalize_harness_overrides_for_cwd(harness_overrides, &config.cwd)?;
|
||||
let thread_manager = Arc::new(ThreadManager::new(
|
||||
config.codex_home.clone(),
|
||||
auth_manager.clone(),
|
||||
@@ -979,6 +1043,10 @@ impl App {
|
||||
auth_manager: auth_manager.clone(),
|
||||
config,
|
||||
active_profile,
|
||||
cli_kv_overrides,
|
||||
harness_overrides,
|
||||
runtime_approval_policy_override: None,
|
||||
runtime_sandbox_policy_override: None,
|
||||
file_search,
|
||||
enhanced_keys_supported,
|
||||
transcript_cells: Vec::new(),
|
||||
@@ -1203,6 +1271,34 @@ impl App {
|
||||
.await?
|
||||
{
|
||||
SessionSelection::Resume(path) => {
|
||||
let current_cwd = self.config.cwd.clone();
|
||||
let resume_cwd = match crate::resolve_cwd_for_resume_or_fork(
|
||||
tui,
|
||||
¤t_cwd,
|
||||
&path,
|
||||
CwdPromptAction::Resume,
|
||||
true,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Some(cwd) => cwd,
|
||||
None => current_cwd.clone(),
|
||||
};
|
||||
let mut resume_config = if crate::cwds_differ(¤t_cwd, &resume_cwd) {
|
||||
match self.rebuild_config_for_cwd(resume_cwd).await {
|
||||
Ok(cfg) => cfg,
|
||||
Err(err) => {
|
||||
self.chat_widget.add_error_message(format!(
|
||||
"Failed to rebuild configuration for resume: {err}"
|
||||
));
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No rebuild needed: current_cwd comes from self.config.cwd.
|
||||
self.config.clone()
|
||||
};
|
||||
self.apply_runtime_policy_overrides(&mut resume_config);
|
||||
let summary = session_summary(
|
||||
self.chat_widget.token_usage(),
|
||||
self.chat_widget.thread_id(),
|
||||
@@ -1210,7 +1306,7 @@ impl App {
|
||||
match self
|
||||
.server
|
||||
.resume_thread_from_rollout(
|
||||
self.config.clone(),
|
||||
resume_config.clone(),
|
||||
path.clone(),
|
||||
self.auth_manager.clone(),
|
||||
)
|
||||
@@ -1218,6 +1314,11 @@ impl App {
|
||||
{
|
||||
Ok(resumed) => {
|
||||
self.shutdown_current_thread().await;
|
||||
self.config = resume_config;
|
||||
self.file_search = FileSearchManager::new(
|
||||
self.config.cwd.clone(),
|
||||
self.app_event_tx.clone(),
|
||||
);
|
||||
let init = self.chatwidget_init_for_forked_or_resumed_thread(
|
||||
tui,
|
||||
self.config.clone(),
|
||||
@@ -1401,10 +1502,8 @@ impl App {
|
||||
AppEvent::UpdateModel(model) => {
|
||||
self.chat_widget.set_model(&model);
|
||||
}
|
||||
AppEvent::UpdateCollaborationMode(mode) => {
|
||||
let model = mode.model().to_string();
|
||||
self.chat_widget.set_collaboration_mode(mode);
|
||||
self.chat_widget.set_model(&model);
|
||||
AppEvent::UpdateCollaborationMode(mask) => {
|
||||
self.chat_widget.set_collaboration_mask(mask);
|
||||
}
|
||||
AppEvent::OpenReasoningPopup { model } => {
|
||||
self.chat_widget.open_reasoning_popup(model);
|
||||
@@ -1660,6 +1759,13 @@ impl App {
|
||||
}
|
||||
}
|
||||
AppEvent::UpdateAskForApprovalPolicy(policy) => {
|
||||
self.runtime_approval_policy_override = Some(policy);
|
||||
if let Err(err) = self.config.approval_policy.set(policy) {
|
||||
tracing::warn!(%err, "failed to set approval policy on app config");
|
||||
self.chat_widget
|
||||
.add_error_message(format!("Failed to set approval policy: {err}"));
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
self.chat_widget.set_approval_policy(policy);
|
||||
}
|
||||
AppEvent::UpdateSandboxPolicy(policy) => {
|
||||
@@ -1688,6 +1794,8 @@ impl App {
|
||||
.add_error_message(format!("Failed to set sandbox policy: {err}"));
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
self.runtime_sandbox_policy_override =
|
||||
Some(self.config.sandbox_policy.get().clone());
|
||||
|
||||
// If sandbox policy becomes workspace-write or read-only, run the Windows world-writable scan.
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -2014,14 +2122,14 @@ impl App {
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
rollout_path: PathBuf::new(),
|
||||
rollout_path: Some(PathBuf::new()),
|
||||
});
|
||||
session_configured.session_id = thread_id;
|
||||
session_configured.forked_from_id = None;
|
||||
session_configured.history_log_id = 0;
|
||||
session_configured.history_entry_count = 0;
|
||||
session_configured.initial_messages = None;
|
||||
session_configured.rollout_path = PathBuf::new();
|
||||
session_configured.rollout_path = Some(PathBuf::new());
|
||||
session_configured
|
||||
}
|
||||
|
||||
@@ -2236,6 +2344,7 @@ mod tests {
|
||||
use codex_core::CodexAuth;
|
||||
use codex_core::ThreadManager;
|
||||
use codex_core::config::ConfigBuilder;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::models_manager::manager::ModelsManager;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::Event;
|
||||
@@ -2254,6 +2363,25 @@ mod tests {
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn normalize_harness_overrides_resolves_relative_add_dirs() -> Result<()> {
|
||||
let temp_dir = tempdir()?;
|
||||
let base_cwd = temp_dir.path().join("base");
|
||||
std::fs::create_dir_all(&base_cwd)?;
|
||||
|
||||
let overrides = ConfigOverrides {
|
||||
additional_writable_roots: vec![PathBuf::from("rel")],
|
||||
..Default::default()
|
||||
};
|
||||
let normalized = normalize_harness_overrides_for_cwd(overrides, &base_cwd)?;
|
||||
|
||||
assert_eq!(
|
||||
normalized.additional_writable_roots,
|
||||
vec![base_cwd.join("rel")]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn make_test_app() -> App {
|
||||
let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await;
|
||||
let config = chat_widget.config_ref().clone();
|
||||
@@ -2275,6 +2403,10 @@ mod tests {
|
||||
auth_manager,
|
||||
config,
|
||||
active_profile: None,
|
||||
cli_kv_overrides: Vec::new(),
|
||||
harness_overrides: ConfigOverrides::default(),
|
||||
runtime_approval_policy_override: None,
|
||||
runtime_sandbox_policy_override: None,
|
||||
file_search,
|
||||
transcript_cells: Vec::new(),
|
||||
overlay: None,
|
||||
@@ -2323,6 +2455,10 @@ mod tests {
|
||||
auth_manager,
|
||||
config,
|
||||
active_profile: None,
|
||||
cli_kv_overrides: Vec::new(),
|
||||
harness_overrides: ConfigOverrides::default(),
|
||||
runtime_approval_policy_override: None,
|
||||
runtime_sandbox_policy_override: None,
|
||||
file_search,
|
||||
transcript_cells: Vec::new(),
|
||||
overlay: None,
|
||||
@@ -2576,7 +2712,7 @@ mod tests {
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
rollout_path: PathBuf::new(),
|
||||
rollout_path: Some(PathBuf::new()),
|
||||
};
|
||||
Arc::new(new_session_info(
|
||||
app.chat_widget.config_ref(),
|
||||
@@ -2628,7 +2764,7 @@ mod tests {
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
rollout_path: PathBuf::new(),
|
||||
rollout_path: Some(PathBuf::new()),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -2673,7 +2809,7 @@ mod tests {
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
rollout_path: PathBuf::new(),
|
||||
rollout_path: Some(PathBuf::new()),
|
||||
};
|
||||
|
||||
app.chat_widget.handle_codex_event(Event {
|
||||
|
||||
@@ -23,7 +23,7 @@ use crate::history_cell::HistoryCell;
|
||||
use codex_core::features::Feature;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::config_types::CollaborationModeMask;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -103,8 +103,8 @@ pub(crate) enum AppEvent {
|
||||
/// Update the current model slug in the running app and widget.
|
||||
UpdateModel(String),
|
||||
|
||||
/// Update the current collaboration mode in the running app and widget.
|
||||
UpdateCollaborationMode(CollaborationMode),
|
||||
/// Update the active collaboration mask in the running app and widget.
|
||||
UpdateCollaborationMode(CollaborationModeMask),
|
||||
|
||||
/// Persist the selected model and reasoning effort to the appropriate config.
|
||||
PersistModelSelection {
|
||||
@@ -240,10 +240,10 @@ pub(crate) enum AppEvent {
|
||||
/// Open the custom prompt option from the review popup.
|
||||
OpenReviewCustomPrompt,
|
||||
|
||||
/// Submit a user message with an explicit collaboration mode.
|
||||
/// Submit a user message with an explicit collaboration mask.
|
||||
SubmitUserMessageWithMode {
|
||||
text: String,
|
||||
collaboration_mode: CollaborationMode,
|
||||
collaboration_mode: CollaborationModeMask,
|
||||
},
|
||||
|
||||
/// Open the approval popup.
|
||||
|
||||
@@ -2039,15 +2039,6 @@ impl ChatComposer {
|
||||
{
|
||||
self.handle_paste(pasted);
|
||||
}
|
||||
// Backspace at the start of an image placeholder should delete that placeholder (rather
|
||||
// than deleting content before it). Do this without scanning the full text by consulting
|
||||
// the textarea's element list.
|
||||
if matches!(input.code, KeyCode::Backspace)
|
||||
&& self.try_remove_image_element_at_cursor_start()
|
||||
{
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
|
||||
// For non-char inputs (or after flushing), handle normally.
|
||||
// Track element removals so we can drop any corresponding placeholders without scanning
|
||||
// the full text. (Placeholders are atomic elements; when deleted, the element disappears.)
|
||||
@@ -2086,29 +2077,6 @@ impl ChatComposer {
|
||||
(InputResult::None, true)
|
||||
}
|
||||
|
||||
fn try_remove_image_element_at_cursor_start(&mut self) -> bool {
|
||||
if self.attached_images.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let p = self.textarea.cursor();
|
||||
let Some(payload) = self.textarea.element_payload_starting_at(p) else {
|
||||
return false;
|
||||
};
|
||||
let Some(idx) = self
|
||||
.attached_images
|
||||
.iter()
|
||||
.position(|img| img.placeholder == payload)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
self.textarea.replace_range(p..p + payload.len(), "");
|
||||
self.attached_images.remove(idx);
|
||||
self.relabel_attached_images_and_update_placeholders();
|
||||
true
|
||||
}
|
||||
|
||||
fn reconcile_deleted_elements(&mut self, elements_before: Vec<String>) {
|
||||
let elements_after: HashSet<String> =
|
||||
self.textarea.element_payloads().into_iter().collect();
|
||||
@@ -4662,8 +4630,7 @@ mod tests {
|
||||
assert!(!composer.textarea.text().contains(&placeholder));
|
||||
assert!(composer.attached_images.is_empty());
|
||||
|
||||
// Re-add and test backspace in middle: should break the placeholder string
|
||||
// and drop the image mapping (same as text placeholder behavior).
|
||||
// Re-add and ensure backspace at element start does not delete the placeholder.
|
||||
composer.attach_image(path);
|
||||
let placeholder2 = composer.attached_images[0].placeholder.clone();
|
||||
// Move cursor to roughly middle of placeholder
|
||||
@@ -4671,8 +4638,8 @@ mod tests {
|
||||
let mid_pos = start_pos + (placeholder2.len() / 2);
|
||||
composer.textarea.set_cursor(mid_pos);
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||||
assert!(!composer.textarea.text().contains(&placeholder2));
|
||||
assert!(composer.attached_images.is_empty());
|
||||
assert!(composer.textarea.text().contains(&placeholder2));
|
||||
assert_eq!(composer.attached_images.len(), 1);
|
||||
} else {
|
||||
panic!("Placeholder not found in textarea");
|
||||
}
|
||||
@@ -4852,7 +4819,7 @@ mod tests {
|
||||
assert_eq!(composer.textarea.text(), "[Image #1][Image #2]");
|
||||
assert_eq!(composer.attached_images.len(), 2);
|
||||
|
||||
// Delete the first element using normal textarea editing (Delete at cursor start).
|
||||
// Delete the first element using normal textarea editing (forward Delete at cursor start).
|
||||
composer.textarea.set_cursor(0);
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE));
|
||||
|
||||
|
||||
@@ -766,12 +766,6 @@ impl TextArea {
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn element_payload_starting_at(&self, pos: usize) -> Option<String> {
|
||||
let pos = pos.min(self.text.len());
|
||||
let elem = self.elements.iter().find(|e| e.range.start == pos)?;
|
||||
self.text.get(elem.range.clone()).map(str::to_string)
|
||||
}
|
||||
|
||||
/// Renames a single text element in-place, keeping it atomic.
|
||||
///
|
||||
/// Use this when the element payload is an identifier (e.g. a placeholder) that must be
|
||||
@@ -1327,6 +1321,21 @@ mod tests {
|
||||
assert_eq!(t.text(), "b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_forward_deletes_element_at_left_edge() {
|
||||
let mut t = TextArea::new();
|
||||
t.insert_str("a");
|
||||
t.insert_element("<element>");
|
||||
t.insert_str("b");
|
||||
|
||||
let elem_start = t.elements[0].range.start;
|
||||
t.set_cursor(elem_start);
|
||||
t.delete_forward(1);
|
||||
|
||||
assert_eq!(t.text(), "ab");
|
||||
assert_eq!(t.cursor(), elem_start);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_backward_word_and_kill_line_variants() {
|
||||
// delete backward word at end removes the whole previous word
|
||||
|
||||
+197
-144
@@ -93,6 +93,7 @@ use codex_protocol::ThreadId;
|
||||
use codex_protocol::account::PlanType;
|
||||
use codex_protocol::approvals::ElicitationRequestEvent;
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::config_types::CollaborationModeMask;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::config_types::Settings;
|
||||
use codex_protocol::models::local_image_label_text;
|
||||
@@ -415,12 +416,12 @@ pub(crate) struct ChatWidget {
|
||||
/// where the overlay may briefly treat new tail content as already cached.
|
||||
active_cell_revision: u64,
|
||||
config: Config,
|
||||
/// Stored collaboration mode with model and reasoning effort.
|
||||
/// The unmasked collaboration mode settings (always Custom mode).
|
||||
///
|
||||
/// When collaboration modes feature is enabled, this is initialized to the first preset.
|
||||
/// When disabled, this is Custom. The model and reasoning effort are stored here instead of
|
||||
/// being read from config or current_model.
|
||||
stored_collaboration_mode: CollaborationMode,
|
||||
/// Masks are applied on top of this base mode to derive the effective mode.
|
||||
current_collaboration_mode: CollaborationMode,
|
||||
/// The currently active collaboration mask, if any.
|
||||
active_collaboration_mask: Option<CollaborationModeMask>,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
models_manager: Arc<ModelsManager>,
|
||||
otel_manager: OtelManager,
|
||||
@@ -727,20 +728,16 @@ impl ChatWidget {
|
||||
self.set_skills(None);
|
||||
self.thread_id = Some(event.session_id);
|
||||
self.forked_from = event.forked_from_id;
|
||||
self.current_rollout_path = Some(event.rollout_path.clone());
|
||||
self.current_rollout_path = event.rollout_path.clone();
|
||||
let initial_messages = event.initial_messages.clone();
|
||||
let model_for_header = event.model.clone();
|
||||
self.session_header.set_model(&model_for_header);
|
||||
// Only update stored collaboration settings when collaboration modes are disabled.
|
||||
// When enabled, we preserve the selected variant (Plan/Pair/Execute/Custom) and its
|
||||
// instructions as-is; the session configured event should not override it.
|
||||
if !self.collaboration_modes_enabled() {
|
||||
self.stored_collaboration_mode = self.stored_collaboration_mode.with_updates(
|
||||
Some(model_for_header.clone()),
|
||||
Some(event.reasoning_effort),
|
||||
None,
|
||||
);
|
||||
}
|
||||
self.current_collaboration_mode = self.current_collaboration_mode.with_updates(
|
||||
Some(model_for_header.clone()),
|
||||
Some(event.reasoning_effort),
|
||||
None,
|
||||
);
|
||||
self.refresh_model_display();
|
||||
let session_info_cell = history_cell::new_session_info(
|
||||
&self.config,
|
||||
&model_for_header,
|
||||
@@ -910,7 +907,7 @@ impl ChatWidget {
|
||||
if !self.queued_user_messages.is_empty() {
|
||||
return;
|
||||
}
|
||||
if self.stored_collaboration_mode.mode != ModeKind::Plan {
|
||||
if self.active_mode_kind() != ModeKind::Plan {
|
||||
return;
|
||||
}
|
||||
let has_message = last_agent_message.is_some_and(|message| !message.trim().is_empty());
|
||||
@@ -932,14 +929,14 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
fn open_plan_implementation_prompt(&mut self) {
|
||||
let code_mode = collaboration_modes::code_mode(self.models_manager.as_ref());
|
||||
let (implement_actions, implement_disabled_reason) = match code_mode {
|
||||
Some(collaboration_mode) => {
|
||||
let code_mask = collaboration_modes::code_mask(self.models_manager.as_ref());
|
||||
let (implement_actions, implement_disabled_reason) = match code_mask {
|
||||
Some(mask) => {
|
||||
let user_text = PLAN_IMPLEMENTATION_CODING_MESSAGE.to_string();
|
||||
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::SubmitUserMessageWithMode {
|
||||
text: user_text.clone(),
|
||||
collaboration_mode: collaboration_mode.clone(),
|
||||
collaboration_mode: mask.clone(),
|
||||
});
|
||||
})];
|
||||
(actions, None)
|
||||
@@ -1930,23 +1927,25 @@ impl ChatWidget {
|
||||
let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string();
|
||||
let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), thread_manager);
|
||||
|
||||
let model_for_header = model.unwrap_or_else(|| DEFAULT_MODEL_DISPLAY_NAME.to_string());
|
||||
let model_override = model.as_deref();
|
||||
let model_for_header = model
|
||||
.clone()
|
||||
.unwrap_or_else(|| DEFAULT_MODEL_DISPLAY_NAME.to_string());
|
||||
let active_collaboration_mask =
|
||||
Self::initial_collaboration_mask(&config, models_manager.as_ref(), model_override);
|
||||
let header_model = active_collaboration_mask
|
||||
.as_ref()
|
||||
.and_then(|mask| mask.model.clone())
|
||||
.unwrap_or_else(|| model_for_header.clone());
|
||||
let fallback_custom = Settings {
|
||||
model: model_for_header.clone(),
|
||||
model: header_model.clone(),
|
||||
reasoning_effort: None,
|
||||
developer_instructions: None,
|
||||
};
|
||||
let stored_collaboration_mode = if config.features.enabled(Feature::CollaborationModes) {
|
||||
initial_collaboration_mode(
|
||||
models_manager.as_ref(),
|
||||
fallback_custom,
|
||||
config.experimental_mode,
|
||||
)
|
||||
} else {
|
||||
CollaborationMode {
|
||||
mode: ModeKind::Custom,
|
||||
settings: fallback_custom,
|
||||
}
|
||||
// Collaboration modes start in Custom mode (not activated).
|
||||
let current_collaboration_mode = CollaborationMode {
|
||||
mode: ModeKind::Custom,
|
||||
settings: fallback_custom,
|
||||
};
|
||||
|
||||
let active_cell = Some(Self::placeholder_session_header_cell(&config));
|
||||
@@ -1970,11 +1969,12 @@ impl ChatWidget {
|
||||
config,
|
||||
skills_all: Vec::new(),
|
||||
skills_initial_state: None,
|
||||
stored_collaboration_mode,
|
||||
current_collaboration_mode,
|
||||
active_collaboration_mask,
|
||||
auth_manager,
|
||||
models_manager,
|
||||
otel_manager,
|
||||
session_header: SessionHeader::new(model_for_header),
|
||||
session_header: SessionHeader::new(header_model),
|
||||
initial_user_message,
|
||||
token_info: None,
|
||||
rate_limit_snapshot: None,
|
||||
@@ -2051,23 +2051,25 @@ impl ChatWidget {
|
||||
let mut rng = rand::rng();
|
||||
let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string();
|
||||
|
||||
let model_for_header = model.unwrap_or_else(|| DEFAULT_MODEL_DISPLAY_NAME.to_string());
|
||||
let model_override = model.as_deref();
|
||||
let model_for_header = model
|
||||
.clone()
|
||||
.unwrap_or_else(|| DEFAULT_MODEL_DISPLAY_NAME.to_string());
|
||||
let active_collaboration_mask =
|
||||
Self::initial_collaboration_mask(&config, models_manager.as_ref(), model_override);
|
||||
let header_model = active_collaboration_mask
|
||||
.as_ref()
|
||||
.and_then(|mask| mask.model.clone())
|
||||
.unwrap_or_else(|| model_for_header.clone());
|
||||
let fallback_custom = Settings {
|
||||
model: model_for_header.clone(),
|
||||
model: header_model.clone(),
|
||||
reasoning_effort: None,
|
||||
developer_instructions: None,
|
||||
};
|
||||
let stored_collaboration_mode = if config.features.enabled(Feature::CollaborationModes) {
|
||||
initial_collaboration_mode(
|
||||
models_manager.as_ref(),
|
||||
fallback_custom,
|
||||
config.experimental_mode,
|
||||
)
|
||||
} else {
|
||||
CollaborationMode {
|
||||
mode: ModeKind::Custom,
|
||||
settings: fallback_custom,
|
||||
}
|
||||
// Collaboration modes start in Custom mode (not activated).
|
||||
let current_collaboration_mode = CollaborationMode {
|
||||
mode: ModeKind::Custom,
|
||||
settings: fallback_custom,
|
||||
};
|
||||
|
||||
let active_cell = Some(Self::placeholder_session_header_cell(&config));
|
||||
@@ -2091,11 +2093,12 @@ impl ChatWidget {
|
||||
config,
|
||||
skills_all: Vec::new(),
|
||||
skills_initial_state: None,
|
||||
stored_collaboration_mode,
|
||||
current_collaboration_mode,
|
||||
active_collaboration_mask,
|
||||
auth_manager,
|
||||
models_manager,
|
||||
otel_manager,
|
||||
session_header: SessionHeader::new(model_for_header),
|
||||
session_header: SessionHeader::new(header_model),
|
||||
initial_user_message,
|
||||
token_info: None,
|
||||
rate_limit_snapshot: None,
|
||||
@@ -2171,7 +2174,16 @@ impl ChatWidget {
|
||||
let mut rng = rand::rng();
|
||||
let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string();
|
||||
|
||||
let header_model = model.unwrap_or_else(|| session_configured.model.clone());
|
||||
let model_override = model.as_deref();
|
||||
let header_model = model
|
||||
.clone()
|
||||
.unwrap_or_else(|| session_configured.model.clone());
|
||||
let active_collaboration_mask =
|
||||
Self::initial_collaboration_mask(&config, models_manager.as_ref(), model_override);
|
||||
let header_model = active_collaboration_mask
|
||||
.as_ref()
|
||||
.and_then(|mask| mask.model.clone())
|
||||
.unwrap_or(header_model);
|
||||
|
||||
let codex_op_tx =
|
||||
spawn_agent_from_existing(conversation, session_configured, app_event_tx.clone());
|
||||
@@ -2181,17 +2193,10 @@ impl ChatWidget {
|
||||
reasoning_effort: None,
|
||||
developer_instructions: None,
|
||||
};
|
||||
let stored_collaboration_mode = if config.features.enabled(Feature::CollaborationModes) {
|
||||
initial_collaboration_mode(
|
||||
models_manager.as_ref(),
|
||||
fallback_custom,
|
||||
config.experimental_mode,
|
||||
)
|
||||
} else {
|
||||
CollaborationMode {
|
||||
mode: ModeKind::Custom,
|
||||
settings: fallback_custom,
|
||||
}
|
||||
// Collaboration modes start in Custom mode (not activated).
|
||||
let current_collaboration_mode = CollaborationMode {
|
||||
mode: ModeKind::Custom,
|
||||
settings: fallback_custom,
|
||||
};
|
||||
|
||||
let mut widget = Self {
|
||||
@@ -2213,7 +2218,8 @@ impl ChatWidget {
|
||||
config,
|
||||
skills_all: Vec::new(),
|
||||
skills_initial_state: None,
|
||||
stored_collaboration_mode,
|
||||
current_collaboration_mode,
|
||||
active_collaboration_mask,
|
||||
auth_manager,
|
||||
models_manager,
|
||||
otel_manager,
|
||||
@@ -2816,18 +2822,24 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
let effective_mode = self.effective_collaboration_mode();
|
||||
let collaboration_mode = if self.collaboration_modes_enabled() {
|
||||
self.active_collaboration_mask
|
||||
.as_ref()
|
||||
.map(|_| effective_mode.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let op = Op::UserTurn {
|
||||
items,
|
||||
cwd: self.config.cwd.clone(),
|
||||
approval_policy: self.config.approval_policy.value(),
|
||||
sandbox_policy: self.config.sandbox_policy.get().clone(),
|
||||
model: self.stored_collaboration_mode.model().to_string(),
|
||||
effort: self.stored_collaboration_mode.reasoning_effort(),
|
||||
model: effective_mode.model().to_string(),
|
||||
effort: effective_mode.reasoning_effort(),
|
||||
summary: self.config.model_reasoning_summary,
|
||||
final_output_json_schema: None,
|
||||
collaboration_mode: self
|
||||
.collaboration_modes_enabled()
|
||||
.then(|| self.stored_collaboration_mode.clone()),
|
||||
collaboration_mode,
|
||||
personality: None,
|
||||
};
|
||||
|
||||
@@ -3180,7 +3192,7 @@ impl ChatWidget {
|
||||
.map(|ti| &ti.total_token_usage)
|
||||
.unwrap_or(&default_usage);
|
||||
let collaboration_mode = self.collaboration_mode_label();
|
||||
let reasoning_effort_override = Some(self.stored_collaboration_mode.reasoning_effort());
|
||||
let reasoning_effort_override = Some(self.effective_reasoning_effort());
|
||||
self.add_to_history(crate::status::new_status_output(
|
||||
&self.config,
|
||||
self.auth_manager.as_ref(),
|
||||
@@ -3558,23 +3570,24 @@ impl ChatWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
let current_kind = self
|
||||
.active_collaboration_mask
|
||||
.as_ref()
|
||||
.and_then(|mask| mask.mode)
|
||||
.or_else(|| {
|
||||
collaboration_modes::default_mask(self.models_manager.as_ref())
|
||||
.and_then(|mask| mask.mode)
|
||||
});
|
||||
let items: Vec<SelectionItem> = presets
|
||||
.into_iter()
|
||||
.map(|preset| {
|
||||
let name = match preset.mode {
|
||||
ModeKind::Plan => "Plan",
|
||||
ModeKind::Code => "Code",
|
||||
ModeKind::PairProgramming => "Pair Programming",
|
||||
ModeKind::Execute => "Execute",
|
||||
ModeKind::Custom => "Custom",
|
||||
};
|
||||
let is_current =
|
||||
collaboration_modes::same_variant(&self.stored_collaboration_mode, &preset);
|
||||
.map(|mask| {
|
||||
let name = mask.name.clone();
|
||||
let is_current = current_kind == mask.mode;
|
||||
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::UpdateCollaborationMode(preset.clone()));
|
||||
tx.send(AppEvent::UpdateCollaborationMode(mask.clone()));
|
||||
})];
|
||||
SelectionItem {
|
||||
name: name.to_string(),
|
||||
name,
|
||||
is_current,
|
||||
actions,
|
||||
dismiss_on_select: true,
|
||||
@@ -3690,7 +3703,7 @@ impl ChatWidget {
|
||||
let model_slug = preset.model.to_string();
|
||||
let is_current_model = self.current_model() == preset.model.as_str();
|
||||
let highlight_choice = if is_current_model {
|
||||
self.stored_collaboration_mode.reasoning_effort()
|
||||
self.effective_reasoning_effort()
|
||||
} else {
|
||||
default_choice
|
||||
};
|
||||
@@ -4548,21 +4561,15 @@ impl ChatWidget {
|
||||
}
|
||||
if feature == Feature::CollaborationModes {
|
||||
self.bottom_pane.set_collaboration_modes_enabled(enabled);
|
||||
let settings = self.stored_collaboration_mode.settings.clone();
|
||||
let fallback_custom = settings.clone();
|
||||
self.stored_collaboration_mode = if enabled {
|
||||
initial_collaboration_mode(
|
||||
self.models_manager.as_ref(),
|
||||
fallback_custom,
|
||||
self.config.experimental_mode,
|
||||
)
|
||||
} else {
|
||||
CollaborationMode {
|
||||
mode: ModeKind::Custom,
|
||||
settings,
|
||||
}
|
||||
let settings = self.current_collaboration_mode.settings.clone();
|
||||
self.current_collaboration_mode = CollaborationMode {
|
||||
mode: ModeKind::Custom,
|
||||
settings,
|
||||
};
|
||||
self.active_collaboration_mask = None;
|
||||
self.update_collaboration_mode_indicator();
|
||||
self.refresh_model_display();
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4591,31 +4598,52 @@ impl ChatWidget {
|
||||
|
||||
/// Set the reasoning effort in the stored collaboration mode.
|
||||
pub(crate) fn set_reasoning_effort(&mut self, effort: Option<ReasoningEffortConfig>) {
|
||||
self.stored_collaboration_mode =
|
||||
self.stored_collaboration_mode
|
||||
self.current_collaboration_mode =
|
||||
self.current_collaboration_mode
|
||||
.with_updates(None, Some(effort), None);
|
||||
if self.collaboration_modes_enabled()
|
||||
&& let Some(mask) = self.active_collaboration_mask.as_mut()
|
||||
{
|
||||
mask.reasoning_effort = Some(effort);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the model in the widget's config copy and stored collaboration mode.
|
||||
pub(crate) fn set_model(&mut self, model: &str) {
|
||||
self.session_header.set_model(model);
|
||||
self.stored_collaboration_mode =
|
||||
self.stored_collaboration_mode
|
||||
self.current_collaboration_mode =
|
||||
self.current_collaboration_mode
|
||||
.with_updates(Some(model.to_string()), None, None);
|
||||
if self.collaboration_modes_enabled()
|
||||
&& let Some(mask) = self.active_collaboration_mask.as_mut()
|
||||
{
|
||||
mask.model = Some(model.to_string());
|
||||
}
|
||||
self.refresh_model_display();
|
||||
}
|
||||
|
||||
pub(crate) fn current_model(&self) -> &str {
|
||||
self.stored_collaboration_mode.model()
|
||||
if !self.collaboration_modes_enabled() {
|
||||
return self.current_collaboration_mode.model();
|
||||
}
|
||||
self.active_collaboration_mask
|
||||
.as_ref()
|
||||
.and_then(|mask| mask.model.as_deref())
|
||||
.unwrap_or_else(|| self.current_collaboration_mode.model())
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // Used in tests
|
||||
pub(crate) fn stored_collaboration_mode(&self) -> &CollaborationMode {
|
||||
&self.stored_collaboration_mode
|
||||
pub(crate) fn current_collaboration_mode(&self) -> &CollaborationMode {
|
||||
&self.current_collaboration_mode
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn current_reasoning_effort(&self) -> Option<ReasoningEffortConfig> {
|
||||
self.stored_collaboration_mode.reasoning_effort()
|
||||
self.effective_reasoning_effort()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn active_collaboration_mode_kind(&self) -> ModeKind {
|
||||
self.active_mode_kind()
|
||||
}
|
||||
|
||||
fn is_session_configured(&self) -> bool {
|
||||
@@ -4626,6 +4654,57 @@ impl ChatWidget {
|
||||
self.config.features.enabled(Feature::CollaborationModes)
|
||||
}
|
||||
|
||||
fn initial_collaboration_mask(
|
||||
config: &Config,
|
||||
models_manager: &ModelsManager,
|
||||
model_override: Option<&str>,
|
||||
) -> Option<CollaborationModeMask> {
|
||||
if !config.features.enabled(Feature::CollaborationModes) {
|
||||
return None;
|
||||
}
|
||||
let mut mask = match config.experimental_mode {
|
||||
Some(kind) => collaboration_modes::mask_for_kind(models_manager, kind)?,
|
||||
None => collaboration_modes::default_mask(models_manager)?,
|
||||
};
|
||||
if let Some(model_override) = model_override {
|
||||
mask.model = Some(model_override.to_string());
|
||||
}
|
||||
Some(mask)
|
||||
}
|
||||
|
||||
fn active_mode_kind(&self) -> ModeKind {
|
||||
self.active_collaboration_mask
|
||||
.as_ref()
|
||||
.and_then(|mask| mask.mode)
|
||||
.unwrap_or(ModeKind::Custom)
|
||||
}
|
||||
|
||||
fn effective_reasoning_effort(&self) -> Option<ReasoningEffortConfig> {
|
||||
if !self.collaboration_modes_enabled() {
|
||||
return self.current_collaboration_mode.reasoning_effort();
|
||||
}
|
||||
let current_effort = self.current_collaboration_mode.reasoning_effort();
|
||||
self.active_collaboration_mask
|
||||
.as_ref()
|
||||
.and_then(|mask| mask.reasoning_effort)
|
||||
.unwrap_or(current_effort)
|
||||
}
|
||||
|
||||
fn effective_collaboration_mode(&self) -> CollaborationMode {
|
||||
if !self.collaboration_modes_enabled() {
|
||||
return self.current_collaboration_mode.clone();
|
||||
}
|
||||
self.active_collaboration_mask.as_ref().map_or_else(
|
||||
|| self.current_collaboration_mode.clone(),
|
||||
|mask| self.current_collaboration_mode.apply_mask(mask),
|
||||
)
|
||||
}
|
||||
|
||||
fn refresh_model_display(&mut self) {
|
||||
let effective = self.effective_collaboration_mode();
|
||||
self.session_header.set_model(effective.model());
|
||||
}
|
||||
|
||||
fn model_display_name(&self) -> &str {
|
||||
let model = self.current_model();
|
||||
if model.is_empty() {
|
||||
@@ -4640,7 +4719,7 @@ impl ChatWidget {
|
||||
if !self.collaboration_modes_enabled() {
|
||||
return None;
|
||||
}
|
||||
match self.stored_collaboration_mode.mode {
|
||||
match self.active_mode_kind() {
|
||||
ModeKind::Plan => Some("Plan"),
|
||||
ModeKind::Code => Some("Code"),
|
||||
ModeKind::PairProgramming => Some("Pair Programming"),
|
||||
@@ -4653,7 +4732,7 @@ impl ChatWidget {
|
||||
if !self.collaboration_modes_enabled() {
|
||||
return None;
|
||||
}
|
||||
match self.stored_collaboration_mode.mode {
|
||||
match self.active_mode_kind() {
|
||||
ModeKind::Plan => Some(CollaborationModeIndicator::Plan),
|
||||
ModeKind::Code => Some(CollaborationModeIndicator::Code),
|
||||
ModeKind::PairProgramming => Some(CollaborationModeIndicator::PairProgramming),
|
||||
@@ -4673,26 +4752,25 @@ impl ChatWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(next_mode) = collaboration_modes::next_mode(
|
||||
if let Some(next_mask) = collaboration_modes::next_mask(
|
||||
self.models_manager.as_ref(),
|
||||
&self.stored_collaboration_mode,
|
||||
self.active_collaboration_mask.as_ref(),
|
||||
) {
|
||||
self.set_collaboration_mode(next_mode);
|
||||
self.set_collaboration_mask(next_mask);
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the stored collaboration mode.
|
||||
/// Update the active collaboration mask.
|
||||
///
|
||||
/// When collaboration modes are enabled, the current mode is attached to *every*
|
||||
/// submission as `Op::UserTurn { collaboration_mode: Some(...) }`.
|
||||
pub(crate) fn set_collaboration_mode(&mut self, mode: CollaborationMode) {
|
||||
/// When collaboration modes are enabled and a preset is selected (not Custom),
|
||||
/// the current mode is attached to submissions as `Op::UserTurn { collaboration_mode: Some(...) }`.
|
||||
pub(crate) fn set_collaboration_mask(&mut self, mask: CollaborationModeMask) {
|
||||
if !self.collaboration_modes_enabled() {
|
||||
return;
|
||||
}
|
||||
let old_model = self.stored_collaboration_mode.model().to_string();
|
||||
let mode = mode.with_updates(Some(old_model), None, None);
|
||||
self.stored_collaboration_mode = mode;
|
||||
self.active_collaboration_mask = Some(mask);
|
||||
self.update_collaboration_mode_indicator();
|
||||
self.refresh_model_display();
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
@@ -4875,11 +4953,9 @@ impl ChatWidget {
|
||||
pub(crate) fn submit_user_message_with_mode(
|
||||
&mut self,
|
||||
text: String,
|
||||
collaboration_mode: CollaborationMode,
|
||||
collaboration_mode: CollaborationModeMask,
|
||||
) {
|
||||
let model = collaboration_mode.model().to_string();
|
||||
self.set_collaboration_mode(collaboration_mode);
|
||||
self.set_model(&model);
|
||||
self.set_collaboration_mask(collaboration_mode);
|
||||
self.submit_user_message(text.into());
|
||||
}
|
||||
|
||||
@@ -5306,29 +5382,6 @@ fn extract_first_bold(s: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
fn initial_collaboration_mode(
|
||||
models_manager: &ModelsManager,
|
||||
fallback_custom: Settings,
|
||||
desired_mode: Option<ModeKind>,
|
||||
) -> CollaborationMode {
|
||||
if let Some(kind) = desired_mode {
|
||||
if kind == ModeKind::Custom {
|
||||
return CollaborationMode {
|
||||
mode: ModeKind::Custom,
|
||||
settings: fallback_custom,
|
||||
};
|
||||
}
|
||||
if let Some(mode) = collaboration_modes::mode_for_kind(models_manager, kind) {
|
||||
return mode;
|
||||
}
|
||||
}
|
||||
|
||||
collaboration_modes::default_mode(models_manager).unwrap_or(CollaborationMode {
|
||||
mode: ModeKind::Custom,
|
||||
settings: fallback_custom,
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_rate_limits(base_url: String, auth: CodexAuth) -> Option<RateLimitSnapshot> {
|
||||
match BackendClient::from_auth(base_url, &auth) {
|
||||
Ok(client) => match client.get_rate_limits().await {
|
||||
|
||||
@@ -89,6 +89,7 @@ use tempfile::NamedTempFile;
|
||||
use tempfile::tempdir;
|
||||
use tokio::sync::mpsc::error::TryRecvError;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
use toml::Value as TomlValue;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn set_windows_sandbox_enabled(enabled: bool) {
|
||||
@@ -160,7 +161,7 @@ async fn resumed_initial_messages_render_history() {
|
||||
message: "assistant reply".to_string(),
|
||||
}),
|
||||
]),
|
||||
rollout_path: rollout_file.path().to_path_buf(),
|
||||
rollout_path: Some(rollout_file.path().to_path_buf()),
|
||||
};
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
@@ -221,7 +222,7 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() {
|
||||
text_elements: text_elements.clone(),
|
||||
local_images: local_images.clone(),
|
||||
})]),
|
||||
rollout_path: rollout_file.path().to_path_buf(),
|
||||
rollout_path: Some(rollout_file.path().to_path_buf()),
|
||||
};
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
@@ -268,7 +269,7 @@ async fn submission_preserves_text_elements_and_local_images() {
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
rollout_path: rollout_file.path().to_path_buf(),
|
||||
rollout_path: Some(rollout_file.path().to_path_buf()),
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
id: "initial".into(),
|
||||
@@ -777,29 +778,16 @@ async fn make_chatwidget_manual(
|
||||
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test"));
|
||||
let codex_home = cfg.codex_home.clone();
|
||||
let models_manager = Arc::new(ModelsManager::new(codex_home, auth_manager.clone()));
|
||||
let collaboration_modes_enabled = cfg.features.enabled(Feature::CollaborationModes);
|
||||
let reasoning_effort = None;
|
||||
let stored_collaboration_mode = if collaboration_modes_enabled {
|
||||
collaboration_modes::default_mode(models_manager.as_ref()).unwrap_or_else(|| {
|
||||
CollaborationMode {
|
||||
mode: ModeKind::Custom,
|
||||
settings: Settings {
|
||||
model: resolved_model.clone(),
|
||||
reasoning_effort,
|
||||
developer_instructions: None,
|
||||
},
|
||||
}
|
||||
})
|
||||
} else {
|
||||
CollaborationMode {
|
||||
mode: ModeKind::Custom,
|
||||
settings: Settings {
|
||||
model: resolved_model.clone(),
|
||||
reasoning_effort,
|
||||
developer_instructions: None,
|
||||
},
|
||||
}
|
||||
let base_mode = CollaborationMode {
|
||||
mode: ModeKind::Custom,
|
||||
settings: Settings {
|
||||
model: resolved_model.clone(),
|
||||
reasoning_effort,
|
||||
developer_instructions: None,
|
||||
},
|
||||
};
|
||||
let current_collaboration_mode = base_mode;
|
||||
let widget = ChatWidget {
|
||||
app_event_tx,
|
||||
codex_op_tx: op_tx,
|
||||
@@ -807,7 +795,8 @@ async fn make_chatwidget_manual(
|
||||
active_cell: None,
|
||||
active_cell_revision: 0,
|
||||
config: cfg,
|
||||
stored_collaboration_mode,
|
||||
current_collaboration_mode,
|
||||
active_collaboration_mask: None,
|
||||
auth_manager,
|
||||
models_manager,
|
||||
otel_manager,
|
||||
@@ -1214,7 +1203,7 @@ async fn plan_implementation_popup_yes_emits_submit_message_event() {
|
||||
panic!("expected SubmitUserMessageWithMode, got {event:?}");
|
||||
};
|
||||
assert_eq!(text, PLAN_IMPLEMENTATION_CODING_MESSAGE);
|
||||
assert_eq!(collaboration_mode.mode, ModeKind::Code);
|
||||
assert_eq!(collaboration_mode.mode, Some(ModeKind::Code));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1223,7 +1212,7 @@ async fn submit_user_message_with_mode_sets_coding_collaboration_mode() {
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.set_feature_enabled(Feature::CollaborationModes, true);
|
||||
|
||||
let code_mode = collaboration_modes::code_mode(chat.models_manager.as_ref())
|
||||
let code_mode = collaboration_modes::code_mask(chat.models_manager.as_ref())
|
||||
.expect("expected code collaboration mode");
|
||||
chat.submit_user_message_with_mode("Implement the plan.".to_string(), code_mode);
|
||||
|
||||
@@ -1247,14 +1236,10 @@ async fn submit_user_message_with_mode_sets_coding_collaboration_mode() {
|
||||
async fn plan_implementation_popup_skips_replayed_turn_complete() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
|
||||
chat.set_feature_enabled(Feature::CollaborationModes, true);
|
||||
chat.stored_collaboration_mode = CollaborationMode {
|
||||
mode: ModeKind::Plan,
|
||||
settings: Settings {
|
||||
model: chat.current_model().to_string(),
|
||||
reasoning_effort: None,
|
||||
developer_instructions: None,
|
||||
},
|
||||
};
|
||||
let plan_mask =
|
||||
collaboration_modes::mask_for_kind(chat.models_manager.as_ref(), ModeKind::Plan)
|
||||
.expect("expected plan collaboration mask");
|
||||
chat.set_collaboration_mask(plan_mask);
|
||||
|
||||
chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
last_agent_message: Some("Plan details".to_string()),
|
||||
@@ -1271,14 +1256,10 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() {
|
||||
async fn plan_implementation_popup_skips_when_messages_queued() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
|
||||
chat.set_feature_enabled(Feature::CollaborationModes, true);
|
||||
chat.stored_collaboration_mode = CollaborationMode {
|
||||
mode: ModeKind::Plan,
|
||||
settings: Settings {
|
||||
model: chat.current_model().to_string(),
|
||||
reasoning_effort: None,
|
||||
developer_instructions: None,
|
||||
},
|
||||
};
|
||||
let plan_mask =
|
||||
collaboration_modes::mask_for_kind(chat.models_manager.as_ref(), ModeKind::Plan)
|
||||
.expect("expected plan collaboration mask");
|
||||
chat.set_collaboration_mask(plan_mask);
|
||||
chat.bottom_pane.set_task_running(true);
|
||||
chat.queue_user_message("Queued message".into());
|
||||
|
||||
@@ -1295,14 +1276,10 @@ async fn plan_implementation_popup_skips_when_messages_queued() {
|
||||
async fn plan_implementation_popup_shows_on_plan_update_without_message() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
|
||||
chat.set_feature_enabled(Feature::CollaborationModes, true);
|
||||
chat.stored_collaboration_mode = CollaborationMode {
|
||||
mode: ModeKind::Plan,
|
||||
settings: Settings {
|
||||
model: chat.current_model().to_string(),
|
||||
reasoning_effort: None,
|
||||
developer_instructions: None,
|
||||
},
|
||||
};
|
||||
let plan_mask =
|
||||
collaboration_modes::mask_for_kind(chat.models_manager.as_ref(), ModeKind::Plan)
|
||||
.expect("expected plan collaboration mask");
|
||||
chat.set_collaboration_mask(plan_mask);
|
||||
|
||||
chat.on_task_started();
|
||||
chat.on_plan_update(UpdatePlanArgs {
|
||||
@@ -1327,14 +1304,10 @@ async fn plan_implementation_popup_skips_when_rate_limit_prompt_pending() {
|
||||
chat.auth_manager =
|
||||
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
||||
chat.set_feature_enabled(Feature::CollaborationModes, true);
|
||||
chat.stored_collaboration_mode = CollaborationMode {
|
||||
mode: ModeKind::Plan,
|
||||
settings: Settings {
|
||||
model: chat.current_model().to_string(),
|
||||
reasoning_effort: None,
|
||||
developer_instructions: None,
|
||||
},
|
||||
};
|
||||
let plan_mask =
|
||||
collaboration_modes::mask_for_kind(chat.models_manager.as_ref(), ModeKind::Plan)
|
||||
.expect("expected plan collaboration mask");
|
||||
chat.set_collaboration_mask(plan_mask);
|
||||
|
||||
chat.on_task_started();
|
||||
chat.on_plan_update(UpdatePlanArgs {
|
||||
@@ -2222,22 +2195,25 @@ async fn collab_mode_shift_tab_cycles_only_when_enabled_and_idle() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.set_feature_enabled(Feature::CollaborationModes, false);
|
||||
|
||||
let initial = chat.stored_collaboration_mode.clone();
|
||||
let initial = chat.current_collaboration_mode().clone();
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::BackTab));
|
||||
assert_eq!(chat.stored_collaboration_mode, initial);
|
||||
assert_eq!(chat.current_collaboration_mode(), &initial);
|
||||
assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Custom);
|
||||
|
||||
chat.set_feature_enabled(Feature::CollaborationModes, true);
|
||||
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::BackTab));
|
||||
assert_eq!(chat.stored_collaboration_mode.mode, ModeKind::Plan);
|
||||
assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan);
|
||||
assert_eq!(chat.current_collaboration_mode(), &initial);
|
||||
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::BackTab));
|
||||
assert_eq!(chat.stored_collaboration_mode.mode, ModeKind::Code);
|
||||
assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Code);
|
||||
assert_eq!(chat.current_collaboration_mode(), &initial);
|
||||
|
||||
chat.on_task_started();
|
||||
let before = chat.stored_collaboration_mode.clone();
|
||||
let before = chat.active_collaboration_mode_kind();
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::BackTab));
|
||||
assert_eq!(chat.stored_collaboration_mode, before);
|
||||
assert_eq!(chat.active_collaboration_mode_kind(), before);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2254,11 +2230,11 @@ async fn collab_slash_command_opens_picker_and_updates_mode() {
|
||||
);
|
||||
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
let selected_mode = match rx.try_recv() {
|
||||
Ok(AppEvent::UpdateCollaborationMode(mode)) => mode,
|
||||
let selected_mask = match rx.try_recv() {
|
||||
Ok(AppEvent::UpdateCollaborationMode(mask)) => mask,
|
||||
other => panic!("expected UpdateCollaborationMode event, got {other:?}"),
|
||||
};
|
||||
chat.set_collaboration_mode(selected_mode);
|
||||
chat.set_collaboration_mask(selected_mask);
|
||||
|
||||
chat.bottom_pane
|
||||
.set_composer_text("hello".to_string(), Vec::new(), Vec::new());
|
||||
@@ -2298,7 +2274,119 @@ async fn collab_slash_command_opens_picker_and_updates_mode() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn collab_mode_defaults_to_coding_when_enabled() {
|
||||
async fn collaboration_modes_defaults_to_code_on_startup() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
let cfg = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.cli_overrides(vec![(
|
||||
"features.collaboration_modes".to_string(),
|
||||
TomlValue::Boolean(true),
|
||||
)])
|
||||
.build()
|
||||
.await
|
||||
.expect("config");
|
||||
let resolved_model = ModelsManager::get_model_offline(cfg.model.as_deref());
|
||||
let otel_manager = test_otel_manager(&cfg, resolved_model.as_str());
|
||||
let thread_manager = Arc::new(ThreadManager::with_models_provider(
|
||||
CodexAuth::from_api_key("test"),
|
||||
cfg.model_provider.clone(),
|
||||
));
|
||||
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test"));
|
||||
let init = ChatWidgetInit {
|
||||
config: cfg,
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
app_event_tx: AppEventSender::new(unbounded_channel::<AppEvent>().0),
|
||||
initial_user_message: None,
|
||||
enhanced_keys_supported: false,
|
||||
auth_manager,
|
||||
models_manager: thread_manager.get_models_manager(),
|
||||
feedback: codex_feedback::CodexFeedback::new(),
|
||||
is_first_run: true,
|
||||
model: Some(resolved_model.clone()),
|
||||
otel_manager,
|
||||
};
|
||||
|
||||
let chat = ChatWidget::new(init, thread_manager);
|
||||
assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Code);
|
||||
assert_eq!(chat.current_model(), resolved_model);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn experimental_mode_plan_applies_on_startup() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
let cfg = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.cli_overrides(vec![
|
||||
(
|
||||
"features.collaboration_modes".to_string(),
|
||||
TomlValue::Boolean(true),
|
||||
),
|
||||
(
|
||||
"tui.experimental_mode".to_string(),
|
||||
TomlValue::String("plan".to_string()),
|
||||
),
|
||||
])
|
||||
.build()
|
||||
.await
|
||||
.expect("config");
|
||||
let resolved_model = ModelsManager::get_model_offline(cfg.model.as_deref());
|
||||
let otel_manager = test_otel_manager(&cfg, resolved_model.as_str());
|
||||
let thread_manager = Arc::new(ThreadManager::with_models_provider(
|
||||
CodexAuth::from_api_key("test"),
|
||||
cfg.model_provider.clone(),
|
||||
));
|
||||
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test"));
|
||||
let init = ChatWidgetInit {
|
||||
config: cfg,
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
app_event_tx: AppEventSender::new(unbounded_channel::<AppEvent>().0),
|
||||
initial_user_message: None,
|
||||
enhanced_keys_supported: false,
|
||||
auth_manager,
|
||||
models_manager: thread_manager.get_models_manager(),
|
||||
feedback: codex_feedback::CodexFeedback::new(),
|
||||
is_first_run: true,
|
||||
model: Some(resolved_model.clone()),
|
||||
otel_manager,
|
||||
};
|
||||
|
||||
let chat = ChatWidget::new(init, thread_manager);
|
||||
assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan);
|
||||
assert_eq!(chat.current_model(), resolved_model);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_model_updates_active_collaboration_mask() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1")).await;
|
||||
chat.set_feature_enabled(Feature::CollaborationModes, true);
|
||||
let plan_mask =
|
||||
collaboration_modes::mask_for_kind(chat.models_manager.as_ref(), ModeKind::Plan)
|
||||
.expect("expected plan collaboration mask");
|
||||
chat.set_collaboration_mask(plan_mask);
|
||||
|
||||
chat.set_model("gpt-5.1-codex-mini");
|
||||
|
||||
assert_eq!(chat.current_model(), "gpt-5.1-codex-mini");
|
||||
assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_reasoning_effort_updates_active_collaboration_mask() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1")).await;
|
||||
chat.set_feature_enabled(Feature::CollaborationModes, true);
|
||||
let plan_mask =
|
||||
collaboration_modes::mask_for_kind(chat.models_manager.as_ref(), ModeKind::Plan)
|
||||
.expect("expected plan collaboration mask");
|
||||
chat.set_collaboration_mask(plan_mask);
|
||||
|
||||
chat.set_reasoning_effort(None);
|
||||
|
||||
assert_eq!(chat.current_reasoning_effort(), None);
|
||||
assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn collab_mode_is_not_sent_until_selected() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.set_feature_enabled(Feature::CollaborationModes, true);
|
||||
@@ -2308,25 +2396,24 @@ async fn collab_mode_defaults_to_coding_when_enabled() {
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
match next_submit_op(&mut op_rx) {
|
||||
Op::UserTurn {
|
||||
collaboration_mode:
|
||||
Some(CollaborationMode {
|
||||
mode: ModeKind::Code,
|
||||
..
|
||||
}),
|
||||
collaboration_mode,
|
||||
personality: None,
|
||||
..
|
||||
} => {}
|
||||
} => {
|
||||
assert_eq!(collaboration_mode, None);
|
||||
}
|
||||
other => {
|
||||
panic!("expected Op::UserTurn with code collab mode, got {other:?}")
|
||||
panic!("expected Op::UserTurn, got {other:?}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn collab_mode_enabling_sets_coding_default() {
|
||||
async fn collab_mode_enabling_keeps_custom_until_selected() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.set_feature_enabled(Feature::CollaborationModes, true);
|
||||
assert_eq!(chat.stored_collaboration_mode.mode, ModeKind::Code);
|
||||
assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Custom);
|
||||
assert_eq!(chat.current_collaboration_mode().mode, ModeKind::Custom);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -1,70 +1,61 @@
|
||||
use codex_core::models_manager::manager::ModelsManager;
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::config_types::CollaborationModeMask;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
|
||||
fn mode_kind(mode: &CollaborationMode) -> ModeKind {
|
||||
mode.mode
|
||||
}
|
||||
|
||||
fn is_tui_mode(kind: ModeKind) -> bool {
|
||||
matches!(kind, ModeKind::Plan | ModeKind::Code)
|
||||
}
|
||||
|
||||
fn filtered_presets(models_manager: &ModelsManager) -> Vec<CollaborationMode> {
|
||||
fn filtered_presets(models_manager: &ModelsManager) -> Vec<CollaborationModeMask> {
|
||||
models_manager
|
||||
.list_collaboration_modes()
|
||||
.into_iter()
|
||||
.filter(|preset| is_tui_mode(mode_kind(preset)))
|
||||
.filter(|mask| mask.mode.is_some_and(is_tui_mode))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn presets_for_tui(models_manager: &ModelsManager) -> Vec<CollaborationMode> {
|
||||
pub(crate) fn presets_for_tui(models_manager: &ModelsManager) -> Vec<CollaborationModeMask> {
|
||||
filtered_presets(models_manager)
|
||||
}
|
||||
|
||||
pub(crate) fn default_mode(models_manager: &ModelsManager) -> Option<CollaborationMode> {
|
||||
pub(crate) fn default_mask(models_manager: &ModelsManager) -> Option<CollaborationModeMask> {
|
||||
let presets = filtered_presets(models_manager);
|
||||
presets
|
||||
.iter()
|
||||
.find(|preset| preset.mode == ModeKind::Code)
|
||||
.find(|mask| mask.mode == Some(ModeKind::Code))
|
||||
.cloned()
|
||||
.or_else(|| presets.into_iter().next())
|
||||
}
|
||||
|
||||
pub(crate) fn mode_for_kind(
|
||||
pub(crate) fn mask_for_kind(
|
||||
models_manager: &ModelsManager,
|
||||
kind: ModeKind,
|
||||
) -> Option<CollaborationMode> {
|
||||
) -> Option<CollaborationModeMask> {
|
||||
if !is_tui_mode(kind) {
|
||||
return None;
|
||||
}
|
||||
let presets = filtered_presets(models_manager);
|
||||
presets.into_iter().find(|preset| mode_kind(preset) == kind)
|
||||
}
|
||||
|
||||
pub(crate) fn same_variant(a: &CollaborationMode, b: &CollaborationMode) -> bool {
|
||||
mode_kind(a) == mode_kind(b)
|
||||
filtered_presets(models_manager)
|
||||
.into_iter()
|
||||
.find(|mask| mask.mode == Some(kind))
|
||||
}
|
||||
|
||||
/// Cycle to the next collaboration mode preset in list order.
|
||||
pub(crate) fn next_mode(
|
||||
pub(crate) fn next_mask(
|
||||
models_manager: &ModelsManager,
|
||||
current: &CollaborationMode,
|
||||
) -> Option<CollaborationMode> {
|
||||
current: Option<&CollaborationModeMask>,
|
||||
) -> Option<CollaborationModeMask> {
|
||||
let presets = filtered_presets(models_manager);
|
||||
if presets.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let current_kind = mode_kind(current);
|
||||
let current_kind = current.and_then(|mask| mask.mode);
|
||||
let next_index = presets
|
||||
.iter()
|
||||
.position(|preset| mode_kind(preset) == current_kind)
|
||||
.position(|mask| mask.mode == current_kind)
|
||||
.map_or(0, |idx| (idx + 1) % presets.len());
|
||||
presets.get(next_index).cloned()
|
||||
}
|
||||
|
||||
pub(crate) fn code_mode(models_manager: &ModelsManager) -> Option<CollaborationMode> {
|
||||
filtered_presets(models_manager)
|
||||
.into_iter()
|
||||
.find(|preset| mode_kind(preset) == ModeKind::Code)
|
||||
pub(crate) fn code_mask(models_manager: &ModelsManager) -> Option<CollaborationModeMask> {
|
||||
mask_for_kind(models_manager, ModeKind::Code)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
use std::path::Path;
|
||||
|
||||
use crate::key_hint;
|
||||
use crate::render::Insets;
|
||||
use crate::render::renderable::ColumnRenderable;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::render::renderable::RenderableExt as _;
|
||||
use crate::selection_list::selection_option_row;
|
||||
use crate::tui::FrameRequester;
|
||||
use crate::tui::Tui;
|
||||
use crate::tui::TuiEvent;
|
||||
use color_eyre::Result;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::prelude::Widget;
|
||||
use ratatui::style::Stylize as _;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::Clear;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum CwdPromptAction {
|
||||
Resume,
|
||||
Fork,
|
||||
}
|
||||
|
||||
impl CwdPromptAction {
|
||||
fn verb(self) -> &'static str {
|
||||
match self {
|
||||
CwdPromptAction::Resume => "resume",
|
||||
CwdPromptAction::Fork => "fork",
|
||||
}
|
||||
}
|
||||
|
||||
fn past_participle(self) -> &'static str {
|
||||
match self {
|
||||
CwdPromptAction::Resume => "resumed",
|
||||
CwdPromptAction::Fork => "forked",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum CwdSelection {
|
||||
Current,
|
||||
Session,
|
||||
}
|
||||
|
||||
impl CwdSelection {
|
||||
fn next(self) -> Self {
|
||||
match self {
|
||||
CwdSelection::Current => CwdSelection::Session,
|
||||
CwdSelection::Session => CwdSelection::Current,
|
||||
}
|
||||
}
|
||||
|
||||
fn prev(self) -> Self {
|
||||
match self {
|
||||
CwdSelection::Current => CwdSelection::Session,
|
||||
CwdSelection::Session => CwdSelection::Current,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn run_cwd_selection_prompt(
|
||||
tui: &mut Tui,
|
||||
action: CwdPromptAction,
|
||||
current_cwd: &Path,
|
||||
session_cwd: &Path,
|
||||
) -> Result<CwdSelection> {
|
||||
let mut screen = CwdPromptScreen::new(
|
||||
tui.frame_requester(),
|
||||
action,
|
||||
current_cwd.display().to_string(),
|
||||
session_cwd.display().to_string(),
|
||||
);
|
||||
tui.draw(u16::MAX, |frame| {
|
||||
frame.render_widget_ref(&screen, frame.area());
|
||||
})?;
|
||||
|
||||
let events = tui.event_stream();
|
||||
tokio::pin!(events);
|
||||
|
||||
while !screen.is_done() {
|
||||
if let Some(event) = events.next().await {
|
||||
match event {
|
||||
TuiEvent::Key(key_event) => screen.handle_key(key_event),
|
||||
TuiEvent::Paste(_) => {}
|
||||
TuiEvent::Draw => {
|
||||
tui.draw(u16::MAX, |frame| {
|
||||
frame.render_widget_ref(&screen, frame.area());
|
||||
})?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(screen.selection().unwrap_or(CwdSelection::Session))
|
||||
}
|
||||
|
||||
struct CwdPromptScreen {
|
||||
request_frame: FrameRequester,
|
||||
action: CwdPromptAction,
|
||||
current_cwd: String,
|
||||
session_cwd: String,
|
||||
highlighted: CwdSelection,
|
||||
selection: Option<CwdSelection>,
|
||||
}
|
||||
|
||||
impl CwdPromptScreen {
|
||||
fn new(
|
||||
request_frame: FrameRequester,
|
||||
action: CwdPromptAction,
|
||||
current_cwd: String,
|
||||
session_cwd: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
request_frame,
|
||||
action,
|
||||
current_cwd,
|
||||
session_cwd,
|
||||
highlighted: CwdSelection::Session,
|
||||
selection: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_key(&mut self, key_event: KeyEvent) {
|
||||
if key_event.kind == KeyEventKind::Release {
|
||||
return;
|
||||
}
|
||||
if key_event.modifiers.contains(KeyModifiers::CONTROL)
|
||||
&& matches!(key_event.code, KeyCode::Char('c') | KeyCode::Char('d'))
|
||||
{
|
||||
self.select(CwdSelection::Session);
|
||||
return;
|
||||
}
|
||||
match key_event.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => self.set_highlight(self.highlighted.prev()),
|
||||
KeyCode::Down | KeyCode::Char('j') => self.set_highlight(self.highlighted.next()),
|
||||
KeyCode::Char('1') => self.select(CwdSelection::Session),
|
||||
KeyCode::Char('2') => self.select(CwdSelection::Current),
|
||||
KeyCode::Enter => self.select(self.highlighted),
|
||||
KeyCode::Esc => self.select(CwdSelection::Session),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_highlight(&mut self, highlight: CwdSelection) {
|
||||
if self.highlighted != highlight {
|
||||
self.highlighted = highlight;
|
||||
self.request_frame.schedule_frame();
|
||||
}
|
||||
}
|
||||
|
||||
fn select(&mut self, selection: CwdSelection) {
|
||||
self.highlighted = selection;
|
||||
self.selection = Some(selection);
|
||||
self.request_frame.schedule_frame();
|
||||
}
|
||||
|
||||
fn is_done(&self) -> bool {
|
||||
self.selection.is_some()
|
||||
}
|
||||
|
||||
fn selection(&self) -> Option<CwdSelection> {
|
||||
self.selection
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for &CwdPromptScreen {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
Clear.render(area, buf);
|
||||
let mut column = ColumnRenderable::new();
|
||||
|
||||
let action_verb = self.action.verb();
|
||||
let action_past = self.action.past_participle();
|
||||
let current_cwd = self.current_cwd.as_str();
|
||||
let session_cwd = self.session_cwd.as_str();
|
||||
|
||||
column.push("");
|
||||
column.push(Line::from(vec![
|
||||
"Choose working directory to ".into(),
|
||||
action_verb.bold(),
|
||||
" this session".into(),
|
||||
]));
|
||||
column.push("");
|
||||
column.push(
|
||||
Line::from(format!(
|
||||
"Session = latest cwd recorded in the {action_past} session"
|
||||
))
|
||||
.dim()
|
||||
.inset(Insets::tlbr(0, 2, 0, 0)),
|
||||
);
|
||||
column.push(
|
||||
Line::from("Current = your current working directory".dim())
|
||||
.inset(Insets::tlbr(0, 2, 0, 0)),
|
||||
);
|
||||
column.push("");
|
||||
column.push(selection_option_row(
|
||||
0,
|
||||
format!("Use session directory ({session_cwd})"),
|
||||
self.highlighted == CwdSelection::Session,
|
||||
));
|
||||
column.push(selection_option_row(
|
||||
1,
|
||||
format!("Use current directory ({current_cwd})"),
|
||||
self.highlighted == CwdSelection::Current,
|
||||
));
|
||||
column.push("");
|
||||
column.push(
|
||||
Line::from(vec![
|
||||
"Press ".dim(),
|
||||
key_hint::plain(KeyCode::Enter).into(),
|
||||
" to continue".dim(),
|
||||
])
|
||||
.inset(Insets::tlbr(0, 2, 0, 0)),
|
||||
);
|
||||
column.render(area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_backend::VT100Backend;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::Terminal;
|
||||
|
||||
fn new_prompt() -> CwdPromptScreen {
|
||||
CwdPromptScreen::new(
|
||||
FrameRequester::test_dummy(),
|
||||
CwdPromptAction::Resume,
|
||||
"/Users/example/current".to_string(),
|
||||
"/Users/example/session".to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cwd_prompt_snapshot() {
|
||||
let screen = new_prompt();
|
||||
let mut terminal = Terminal::new(VT100Backend::new(80, 14)).expect("terminal");
|
||||
terminal
|
||||
.draw(|frame| frame.render_widget_ref(&screen, frame.area()))
|
||||
.expect("render cwd prompt");
|
||||
insta::assert_snapshot!("cwd_prompt_modal", terminal.backend());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cwd_prompt_fork_snapshot() {
|
||||
let screen = CwdPromptScreen::new(
|
||||
FrameRequester::test_dummy(),
|
||||
CwdPromptAction::Fork,
|
||||
"/Users/example/current".to_string(),
|
||||
"/Users/example/session".to_string(),
|
||||
);
|
||||
let mut terminal = Terminal::new(VT100Backend::new(80, 14)).expect("terminal");
|
||||
terminal
|
||||
.draw(|frame| frame.render_widget_ref(&screen, frame.area()))
|
||||
.expect("render cwd prompt");
|
||||
insta::assert_snapshot!("cwd_prompt_fork_modal", terminal.backend());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cwd_prompt_selects_session_by_default() {
|
||||
let mut screen = new_prompt();
|
||||
screen.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert_eq!(screen.selection(), Some(CwdSelection::Session));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cwd_prompt_can_select_current() {
|
||||
let mut screen = new_prompt();
|
||||
screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||
screen.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert_eq!(screen.selection(), Some(CwdSelection::Current));
|
||||
}
|
||||
}
|
||||
+280
-15
@@ -27,13 +27,19 @@ use codex_core::config_loader::ConfigLoadError;
|
||||
use codex_core::config_loader::format_config_error_with_source;
|
||||
use codex_core::find_thread_path_by_id_str;
|
||||
use codex_core::get_platform_sandbox;
|
||||
use codex_core::path_utils;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::read_session_meta_line;
|
||||
use codex_core::terminal::Multiplexer;
|
||||
use codex_protocol::config_types::AltScreenMode;
|
||||
use codex_protocol::config_types::SandboxMode;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::RolloutLine;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use cwd_prompt::CwdPromptAction;
|
||||
use cwd_prompt::CwdSelection;
|
||||
use std::fs::OpenOptions;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tracing::error;
|
||||
use tracing_appender::non_blocking;
|
||||
@@ -54,6 +60,7 @@ mod collab;
|
||||
mod collaboration_modes;
|
||||
mod color;
|
||||
pub mod custom_terminal;
|
||||
mod cwd_prompt;
|
||||
mod diff_render;
|
||||
mod exec_cell;
|
||||
mod exec_command;
|
||||
@@ -577,25 +584,27 @@ async fn run_ratatui_app(
|
||||
resume_picker::SessionSelection::StartFresh
|
||||
};
|
||||
|
||||
let current_cwd = config.cwd.clone();
|
||||
let allow_prompt = cli.cwd.is_none();
|
||||
let action_and_path_if_resume_or_fork = match &session_selection {
|
||||
resume_picker::SessionSelection::Resume(path) => Some((CwdPromptAction::Resume, path)),
|
||||
resume_picker::SessionSelection::Fork(path) => Some((CwdPromptAction::Fork, path)),
|
||||
_ => None,
|
||||
};
|
||||
let fallback_cwd = match action_and_path_if_resume_or_fork {
|
||||
Some((action, path)) => {
|
||||
resolve_cwd_for_resume_or_fork(&mut tui, ¤t_cwd, path, action, allow_prompt)
|
||||
.await?
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
let config = match &session_selection {
|
||||
resume_picker::SessionSelection::Resume(path)
|
||||
| resume_picker::SessionSelection::Fork(path) => {
|
||||
let history_cwd = match read_session_meta_line(path).await {
|
||||
Ok(meta_line) => Some(meta_line.meta.cwd),
|
||||
Err(err) => {
|
||||
let rollout_path = path.display().to_string();
|
||||
tracing::warn!(
|
||||
%rollout_path,
|
||||
%err,
|
||||
"Failed to read session metadata from rollout"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
resume_picker::SessionSelection::Resume(_) | resume_picker::SessionSelection::Fork(_) => {
|
||||
load_config_or_exit_with_fallback_cwd(
|
||||
cli_kv_overrides.clone(),
|
||||
overrides.clone(),
|
||||
history_cwd,
|
||||
fallback_cwd,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -618,6 +627,8 @@ async fn run_ratatui_app(
|
||||
&mut tui,
|
||||
auth_manager,
|
||||
config,
|
||||
cli_kv_overrides.clone(),
|
||||
overrides.clone(),
|
||||
active_profile,
|
||||
prompt,
|
||||
images,
|
||||
@@ -635,6 +646,77 @@ async fn run_ratatui_app(
|
||||
app_result
|
||||
}
|
||||
|
||||
pub(crate) async fn read_session_cwd(path: &Path) -> Option<PathBuf> {
|
||||
// Prefer the latest TurnContext cwd so resume/fork reflects the most recent
|
||||
// session directory (for the changed-cwd prompt). The alternative would be
|
||||
// mutating the SessionMeta line when the session cwd changes, but the rollout
|
||||
// is an append-only JSONL log and rewriting the head would be error-prone.
|
||||
// When rollouts move to SQLite, we can drop this scan.
|
||||
if let Some(cwd) = parse_latest_turn_context_cwd(path).await {
|
||||
return Some(cwd);
|
||||
}
|
||||
match read_session_meta_line(path).await {
|
||||
Ok(meta_line) => Some(meta_line.meta.cwd),
|
||||
Err(err) => {
|
||||
let rollout_path = path.display().to_string();
|
||||
tracing::warn!(
|
||||
%rollout_path,
|
||||
%err,
|
||||
"Failed to read session metadata from rollout"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn parse_latest_turn_context_cwd(path: &Path) -> Option<PathBuf> {
|
||||
let text = tokio::fs::read_to_string(path).await.ok()?;
|
||||
for line in text.lines().rev() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Ok(rollout_line) = serde_json::from_str::<RolloutLine>(trimmed) else {
|
||||
continue;
|
||||
};
|
||||
if let RolloutItem::TurnContext(item) = rollout_line.item {
|
||||
return Some(item.cwd);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn cwds_differ(current_cwd: &Path, session_cwd: &Path) -> bool {
|
||||
match (
|
||||
path_utils::normalize_for_path_comparison(current_cwd),
|
||||
path_utils::normalize_for_path_comparison(session_cwd),
|
||||
) {
|
||||
(Ok(current), Ok(session)) => current != session,
|
||||
_ => current_cwd != session_cwd,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_cwd_for_resume_or_fork(
|
||||
tui: &mut Tui,
|
||||
current_cwd: &Path,
|
||||
path: &Path,
|
||||
action: CwdPromptAction,
|
||||
allow_prompt: bool,
|
||||
) -> color_eyre::Result<Option<PathBuf>> {
|
||||
let Some(history_cwd) = read_session_cwd(path).await else {
|
||||
return Ok(None);
|
||||
};
|
||||
if allow_prompt && cwds_differ(current_cwd, &history_cwd) {
|
||||
let selection =
|
||||
cwd_prompt::run_cwd_selection_prompt(tui, action, current_cwd, &history_cwd).await?;
|
||||
return Ok(Some(match selection {
|
||||
CwdSelection::Current => current_cwd.to_path_buf(),
|
||||
CwdSelection::Session => history_cwd,
|
||||
}));
|
||||
}
|
||||
Ok(Some(history_cwd))
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::print_stderr,
|
||||
reason = "TUI should no longer be displayed, so we can write to stderr."
|
||||
@@ -772,7 +854,14 @@ fn should_show_login_screen(login_status: LoginStatus, config: &Config) -> bool
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_core::config::ConfigBuilder;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config::ProjectConfig;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::RolloutLine;
|
||||
use codex_protocol::protocol::SessionMeta;
|
||||
use codex_protocol::protocol::SessionMetaLine;
|
||||
use codex_protocol::protocol::TurnContextItem;
|
||||
use serial_test::serial;
|
||||
use tempfile::TempDir;
|
||||
|
||||
@@ -846,4 +935,180 @@ mod tests {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_turn_context(config: &Config, cwd: PathBuf) -> TurnContextItem {
|
||||
let model = config
|
||||
.model
|
||||
.clone()
|
||||
.unwrap_or_else(|| "gpt-5.1".to_string());
|
||||
TurnContextItem {
|
||||
cwd,
|
||||
approval_policy: config.approval_policy.value(),
|
||||
sandbox_policy: config.sandbox_policy.get().clone(),
|
||||
model,
|
||||
personality: None,
|
||||
collaboration_mode: None,
|
||||
effort: config.model_reasoning_effort,
|
||||
summary: config.model_reasoning_summary,
|
||||
user_instructions: None,
|
||||
developer_instructions: None,
|
||||
final_output_json_schema: None,
|
||||
truncation_policy: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_session_cwd_prefers_latest_turn_context() -> std::io::Result<()> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
let config = build_config(&temp_dir).await?;
|
||||
let first = temp_dir.path().join("first");
|
||||
let second = temp_dir.path().join("second");
|
||||
std::fs::create_dir_all(&first)?;
|
||||
std::fs::create_dir_all(&second)?;
|
||||
|
||||
let rollout_path = temp_dir.path().join("rollout.jsonl");
|
||||
let lines = vec![
|
||||
RolloutLine {
|
||||
timestamp: "t0".to_string(),
|
||||
item: RolloutItem::TurnContext(build_turn_context(&config, first)),
|
||||
},
|
||||
RolloutLine {
|
||||
timestamp: "t1".to_string(),
|
||||
item: RolloutItem::TurnContext(build_turn_context(&config, second.clone())),
|
||||
},
|
||||
];
|
||||
let mut text = String::new();
|
||||
for line in lines {
|
||||
text.push_str(&serde_json::to_string(&line).expect("serialize rollout"));
|
||||
text.push('\n');
|
||||
}
|
||||
std::fs::write(&rollout_path, text)?;
|
||||
|
||||
let cwd = read_session_cwd(&rollout_path).await.expect("expected cwd");
|
||||
assert_eq!(cwd, second);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn should_prompt_when_meta_matches_current_but_latest_turn_differs() -> std::io::Result<()>
|
||||
{
|
||||
let temp_dir = TempDir::new()?;
|
||||
let config = build_config(&temp_dir).await?;
|
||||
let current = temp_dir.path().join("current");
|
||||
let latest = temp_dir.path().join("latest");
|
||||
std::fs::create_dir_all(¤t)?;
|
||||
std::fs::create_dir_all(&latest)?;
|
||||
|
||||
let rollout_path = temp_dir.path().join("rollout.jsonl");
|
||||
let session_meta = SessionMeta {
|
||||
cwd: current.clone(),
|
||||
..SessionMeta::default()
|
||||
};
|
||||
let lines = vec![
|
||||
RolloutLine {
|
||||
timestamp: "t0".to_string(),
|
||||
item: RolloutItem::SessionMeta(SessionMetaLine {
|
||||
meta: session_meta,
|
||||
git: None,
|
||||
}),
|
||||
},
|
||||
RolloutLine {
|
||||
timestamp: "t1".to_string(),
|
||||
item: RolloutItem::TurnContext(build_turn_context(&config, latest.clone())),
|
||||
},
|
||||
];
|
||||
let mut text = String::new();
|
||||
for line in lines {
|
||||
text.push_str(&serde_json::to_string(&line).expect("serialize rollout"));
|
||||
text.push('\n');
|
||||
}
|
||||
std::fs::write(&rollout_path, text)?;
|
||||
|
||||
let session_cwd = read_session_cwd(&rollout_path).await.expect("expected cwd");
|
||||
assert_eq!(session_cwd, latest);
|
||||
assert!(cwds_differ(¤t, &session_cwd));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_rebuild_changes_trust_defaults_with_cwd() -> std::io::Result<()> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
let codex_home = temp_dir.path().to_path_buf();
|
||||
let trusted = temp_dir.path().join("trusted");
|
||||
let untrusted = temp_dir.path().join("untrusted");
|
||||
std::fs::create_dir_all(&trusted)?;
|
||||
std::fs::create_dir_all(&untrusted)?;
|
||||
|
||||
// TOML keys need escaped backslashes on Windows paths.
|
||||
let trusted_display = trusted.display().to_string().replace('\\', "\\\\");
|
||||
let untrusted_display = untrusted.display().to_string().replace('\\', "\\\\");
|
||||
let config_toml = format!(
|
||||
r#"[projects."{trusted_display}"]
|
||||
trust_level = "trusted"
|
||||
|
||||
[projects."{untrusted_display}"]
|
||||
trust_level = "untrusted"
|
||||
"#
|
||||
);
|
||||
std::fs::write(temp_dir.path().join("config.toml"), config_toml)?;
|
||||
|
||||
let trusted_overrides = ConfigOverrides {
|
||||
cwd: Some(trusted.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
let trusted_config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.clone())
|
||||
.harness_overrides(trusted_overrides.clone())
|
||||
.build()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
trusted_config.approval_policy.value(),
|
||||
AskForApproval::OnRequest
|
||||
);
|
||||
|
||||
let untrusted_overrides = ConfigOverrides {
|
||||
cwd: Some(untrusted),
|
||||
..trusted_overrides
|
||||
};
|
||||
let untrusted_config = ConfigBuilder::default()
|
||||
.codex_home(codex_home)
|
||||
.harness_overrides(untrusted_overrides)
|
||||
.build()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
untrusted_config.approval_policy.value(),
|
||||
AskForApproval::UnlessTrusted
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_session_cwd_falls_back_to_session_meta() -> std::io::Result<()> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
let _config = build_config(&temp_dir).await?;
|
||||
let session_cwd = temp_dir.path().join("session");
|
||||
std::fs::create_dir_all(&session_cwd)?;
|
||||
|
||||
let rollout_path = temp_dir.path().join("rollout.jsonl");
|
||||
let session_meta = SessionMeta {
|
||||
cwd: session_cwd.clone(),
|
||||
..SessionMeta::default()
|
||||
};
|
||||
let meta_line = RolloutLine {
|
||||
timestamp: "t0".to_string(),
|
||||
item: RolloutItem::SessionMeta(SessionMetaLine {
|
||||
meta: session_meta,
|
||||
git: None,
|
||||
}),
|
||||
};
|
||||
let text = format!(
|
||||
"{}\n",
|
||||
serde_json::to_string(&meta_line).expect("serialize meta")
|
||||
);
|
||||
std::fs::write(&rollout_path, text)?;
|
||||
|
||||
let cwd = read_session_cwd(&rollout_path).await.expect("expected cwd");
|
||||
assert_eq!(cwd, session_cwd);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,6 +311,9 @@ impl WidgetRef for &OnboardingScreen {
|
||||
}
|
||||
let scratch_area = Rect::new(0, 0, width, max_h);
|
||||
let mut scratch = Buffer::empty(scratch_area);
|
||||
if let Step::Welcome(widget) = step {
|
||||
widget.update_layout_area(scratch_area);
|
||||
}
|
||||
step.render_ref(scratch_area, &mut scratch);
|
||||
let h = used_rows(&scratch, width, max_h).min(max_h);
|
||||
if h > 0 {
|
||||
|
||||
@@ -11,6 +11,7 @@ use ratatui::widgets::Clear;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use ratatui::widgets::Wrap;
|
||||
use std::cell::Cell;
|
||||
|
||||
use crate::ascii_animation::AsciiAnimation;
|
||||
use crate::onboarding::onboarding_screen::KeyboardHandler;
|
||||
@@ -19,13 +20,14 @@ use crate::tui::FrameRequester;
|
||||
|
||||
use super::onboarding_screen::StepState;
|
||||
|
||||
const MIN_ANIMATION_HEIGHT: u16 = 20;
|
||||
const MIN_ANIMATION_HEIGHT: u16 = 37;
|
||||
const MIN_ANIMATION_WIDTH: u16 = 60;
|
||||
|
||||
pub(crate) struct WelcomeWidget {
|
||||
pub is_logged_in: bool,
|
||||
animation: AsciiAnimation,
|
||||
animations_enabled: bool,
|
||||
layout_area: Cell<Option<Rect>>,
|
||||
}
|
||||
|
||||
impl KeyboardHandler for WelcomeWidget {
|
||||
@@ -53,8 +55,13 @@ impl WelcomeWidget {
|
||||
is_logged_in,
|
||||
animation: AsciiAnimation::new(request_frame),
|
||||
animations_enabled,
|
||||
layout_area: Cell::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn update_layout_area(&self, area: Rect) {
|
||||
self.layout_area.set(Some(area));
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for &WelcomeWidget {
|
||||
@@ -64,12 +71,14 @@ impl WidgetRef for &WelcomeWidget {
|
||||
self.animation.schedule_next_frame();
|
||||
}
|
||||
|
||||
let layout_area = self.layout_area.get().unwrap_or(area);
|
||||
// Skip the animation entirely when the viewport is too small so we don't clip frames.
|
||||
let show_animation =
|
||||
area.height >= MIN_ANIMATION_HEIGHT && area.width >= MIN_ANIMATION_WIDTH;
|
||||
let show_animation = self.animations_enabled
|
||||
&& layout_area.height >= MIN_ANIMATION_HEIGHT
|
||||
&& layout_area.width >= MIN_ANIMATION_WIDTH;
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
if show_animation && self.animations_enabled {
|
||||
if show_animation {
|
||||
let frame = self.animation.current_frame();
|
||||
lines.extend(frame.lines().map(Into::into));
|
||||
lines.push("".into());
|
||||
@@ -99,6 +108,7 @@ impl StepStateProvider for WelcomeWidget {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
@@ -106,31 +116,37 @@ mod tests {
|
||||
static VARIANT_B: [&str; 1] = ["frame-b"];
|
||||
static VARIANTS: [&[&str]; 2] = [&VARIANT_A, &VARIANT_B];
|
||||
|
||||
fn row_containing(buf: &Buffer, needle: &str) -> Option<u16> {
|
||||
(0..buf.area.height).find(|&y| {
|
||||
let mut row = String::new();
|
||||
for x in 0..buf.area.width {
|
||||
row.push_str(buf[(x, y)].symbol());
|
||||
}
|
||||
row.contains(needle)
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn welcome_renders_animation_on_first_draw() {
|
||||
let widget = WelcomeWidget::new(false, FrameRequester::test_dummy(), true);
|
||||
let area = Rect::new(0, 0, MIN_ANIMATION_WIDTH, MIN_ANIMATION_HEIGHT);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let frame_lines = widget.animation.current_frame().lines().count() as u16;
|
||||
(&widget).render(area, &mut buf);
|
||||
|
||||
let mut found = false;
|
||||
let mut last_non_empty: Option<u16> = None;
|
||||
for y in 0..area.height {
|
||||
for x in 0..area.width {
|
||||
if !buf[(x, y)].symbol().trim().is_empty() {
|
||||
found = true;
|
||||
last_non_empty = Some(y);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let welcome_row = row_containing(&buf, "Welcome");
|
||||
assert_eq!(welcome_row, Some(frame_lines + 1));
|
||||
}
|
||||
|
||||
assert!(found, "expected welcome animation to render characters");
|
||||
let measured_rows = last_non_empty.map(|v| v + 2).unwrap_or(0);
|
||||
assert!(
|
||||
measured_rows >= MIN_ANIMATION_HEIGHT,
|
||||
"expected measurement to report at least {MIN_ANIMATION_HEIGHT} rows, got {measured_rows}"
|
||||
);
|
||||
#[test]
|
||||
fn welcome_skips_animation_below_height_breakpoint() {
|
||||
let widget = WelcomeWidget::new(false, FrameRequester::test_dummy(), true);
|
||||
let area = Rect::new(0, 0, MIN_ANIMATION_WIDTH, MIN_ANIMATION_HEIGHT - 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
(&widget).render(area, &mut buf);
|
||||
|
||||
let welcome_row = row_containing(&buf, "Welcome");
|
||||
assert_eq!(welcome_row, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -139,6 +155,7 @@ mod tests {
|
||||
is_logged_in: false,
|
||||
animation: AsciiAnimation::with_variants(FrameRequester::test_dummy(), &VARIANTS, 0),
|
||||
animations_enabled: true,
|
||||
layout_area: Cell::new(None),
|
||||
};
|
||||
|
||||
let before = widget.animation.current_frame();
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
source: tui/src/cwd_prompt.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
|
||||
Choose working directory to fork this session
|
||||
|
||||
Session = latest cwd recorded in the forked session
|
||||
Current = your current working directory
|
||||
|
||||
› 1. Use session directory (/Users/example/session)
|
||||
2. Use current directory (/Users/example/current)
|
||||
|
||||
Press enter to continue
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
source: tui/src/cwd_prompt.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
|
||||
Choose working directory to resume this session
|
||||
|
||||
Session = latest cwd recorded in the resumed session
|
||||
Current = your current working directory
|
||||
|
||||
› 1. Use session directory (/Users/example/session)
|
||||
2. Use current directory (/Users/example/current)
|
||||
|
||||
Press enter to continue
|
||||
@@ -16,6 +16,7 @@ use codex_windows_sandbox::SandboxPolicy;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::c_void;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use windows_sys::Win32::Foundation::CloseHandle;
|
||||
use windows_sys::Win32::Foundation::GetLastError;
|
||||
@@ -78,13 +79,20 @@ unsafe fn create_job_kill_on_close() -> Result<HANDLE> {
|
||||
Ok(h)
|
||||
}
|
||||
|
||||
fn read_request_file(req_path: &Path) -> Result<String> {
|
||||
let content = std::fs::read_to_string(req_path)
|
||||
.with_context(|| format!("read request file {}", req_path.display()));
|
||||
let _ = std::fs::remove_file(req_path);
|
||||
content
|
||||
}
|
||||
|
||||
pub fn main() -> Result<()> {
|
||||
let mut input = String::new();
|
||||
let mut args = std::env::args().skip(1);
|
||||
if let Some(first) = args.next() {
|
||||
if let Some(rest) = first.strip_prefix("--request-file=") {
|
||||
let req_path = PathBuf::from(rest);
|
||||
input = std::fs::read_to_string(&req_path).context("read request file")?;
|
||||
input = read_request_file(&req_path)?;
|
||||
}
|
||||
}
|
||||
if input.is_empty() {
|
||||
@@ -265,3 +273,21 @@ pub fn main() -> Result<()> {
|
||||
}
|
||||
std::process::exit(exit_code);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::read_request_file;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn removes_request_file_after_read() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let req_path = dir.path().join("request.json");
|
||||
fs::write(&req_path, "{\"ok\":true}").expect("write request");
|
||||
|
||||
let content = read_request_file(&req_path).expect("read request");
|
||||
assert_eq!(content, "{\"ok\":true}");
|
||||
assert!(!req_path.exists(), "request file should be removed");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user