mirror of
https://github.com/openai/codex.git
synced 2026-02-01 22:47:52 +00:00
Compare commits
15 Commits
shijie/req
...
78a359f7fa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78a359f7fa | ||
|
|
274af30525 | ||
|
|
efa9326f08 | ||
|
|
1271d450b1 | ||
|
|
c87a7d9043 | ||
|
|
f72f87fbee | ||
|
|
0a568a47fd | ||
|
|
aeaff26451 | ||
|
|
1478a88eb0 | ||
|
|
80d7a5d7fe | ||
|
|
bffe9b33e9 | ||
|
|
8f0e0300d2 | ||
|
|
b877a2041e | ||
|
|
764f3c7d03 | ||
|
|
93a5e0fe1c |
31
codex-rs/Cargo.lock
generated
31
codex-rs/Cargo.lock
generated
@@ -360,16 +360,19 @@ dependencies = [
|
||||
"objc2-foundation",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.60.2",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
version = "1.7.1"
|
||||
version = "1.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457"
|
||||
checksum = "51d03449bb8ca2cc2ef70869af31463d1ae5ccc8fa3e334b307203fbf815207e"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
@@ -2829,7 +2832,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2926,7 +2929,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"rustix 1.0.8",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3867,7 +3870,7 @@ checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4160,9 +4163,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.28"
|
||||
version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "logos"
|
||||
@@ -5378,7 +5381,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2 0.6.1",
|
||||
"tracing",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5757,7 +5760,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.4.15",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5770,7 +5773,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.9.4",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7071,9 +7074,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.48.0"
|
||||
version = "1.49.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408"
|
||||
checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
@@ -8088,7 +8091,7 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
|
||||
dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -154,6 +154,11 @@ client_request_definitions! {
|
||||
params: v2::ModelListParams,
|
||||
response: v2::ModelListResponse,
|
||||
},
|
||||
/// EXPERIMENTAL - list collaboration mode presets.
|
||||
CollaborationModeList => "collaborationMode/list" {
|
||||
params: v2::CollaborationModeListParams,
|
||||
response: v2::CollaborationModeListResponse,
|
||||
},
|
||||
|
||||
McpServerOauthLogin => "mcpServer/oauth/login" {
|
||||
params: v2::McpServerOauthLoginParams,
|
||||
@@ -878,4 +883,21 @@ mod tests {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_list_collaboration_modes() -> Result<()> {
|
||||
let request = ClientRequest::CollaborationModeList {
|
||||
request_id: RequestId::Integer(7),
|
||||
params: v2::CollaborationModeListParams::default(),
|
||||
};
|
||||
assert_eq!(
|
||||
json!({
|
||||
"method": "collaborationMode/list",
|
||||
"id": 7,
|
||||
"params": {}
|
||||
}),
|
||||
serde_json::to_value(&request)?,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -917,6 +917,20 @@ pub struct ModelListResponse {
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
/// EXPERIMENTAL - list collaboration mode presets.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct CollaborationModeListParams {}
|
||||
|
||||
/// EXPERIMENTAL - collaboration mode presets response.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct CollaborationModeListResponse {
|
||||
pub data: Vec<CollaborationMode>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
|
||||
@@ -86,6 +86,7 @@ Example (from OpenAI's official VSCode extension):
|
||||
- `review/start` — kick off Codex’s automated reviewer for a thread; responds like `turn/start` and emits `item/started`/`item/completed` notifications with `enteredReviewMode` and `exitedReviewMode` items, plus a final assistant `agentMessage` containing the review.
|
||||
- `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation).
|
||||
- `model/list` — list available models (with reasoning effort options).
|
||||
- `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination).
|
||||
- `skills/list` — list skills for one or more `cwd` values (optional `forceReload`).
|
||||
- `skills/config/write` — write user-level skill config by path.
|
||||
- `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes.
|
||||
|
||||
@@ -23,6 +23,8 @@ use codex_app_server_protocol::CancelLoginAccountResponse;
|
||||
use codex_app_server_protocol::CancelLoginAccountStatus;
|
||||
use codex_app_server_protocol::CancelLoginChatGptResponse;
|
||||
use codex_app_server_protocol::ClientRequest;
|
||||
use codex_app_server_protocol::CollaborationModeListParams;
|
||||
use codex_app_server_protocol::CollaborationModeListResponse;
|
||||
use codex_app_server_protocol::CommandExecParams;
|
||||
use codex_app_server_protocol::ConversationGitInfo;
|
||||
use codex_app_server_protocol::ConversationSummary;
|
||||
@@ -436,6 +438,15 @@ impl CodexMessageProcessor {
|
||||
Self::list_models(outgoing, thread_manager, config, request_id, params).await;
|
||||
});
|
||||
}
|
||||
ClientRequest::CollaborationModeList { request_id, params } => {
|
||||
let outgoing = self.outgoing.clone();
|
||||
let thread_manager = self.thread_manager.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
Self::list_collaboration_modes(outgoing, thread_manager, request_id, params)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
ClientRequest::McpServerOauthLogin { request_id, params } => {
|
||||
self.mcp_server_oauth_login(request_id, params).await;
|
||||
}
|
||||
@@ -2371,6 +2382,18 @@ impl CodexMessageProcessor {
|
||||
outgoing.send_response(request_id, response).await;
|
||||
}
|
||||
|
||||
async fn list_collaboration_modes(
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
thread_manager: Arc<ThreadManager>,
|
||||
request_id: RequestId,
|
||||
params: CollaborationModeListParams,
|
||||
) {
|
||||
let CollaborationModeListParams {} = params;
|
||||
let items = thread_manager.list_collaboration_modes();
|
||||
let response = CollaborationModeListResponse { data: items };
|
||||
outgoing.send_response(request_id, response).await;
|
||||
}
|
||||
|
||||
async fn mcp_server_refresh(&self, request_id: RequestId, _params: Option<()>) {
|
||||
let config = match self.load_latest_config().await {
|
||||
Ok(config) => config,
|
||||
@@ -4249,7 +4272,6 @@ mod tests {
|
||||
"cwd": "/",
|
||||
"originator": "codex",
|
||||
"cli_version": "0.0.0",
|
||||
"instructions": null,
|
||||
"model_provider": "test-provider"
|
||||
}),
|
||||
json!({
|
||||
|
||||
@@ -17,6 +17,7 @@ use codex_app_server_protocol::CancelLoginAccountParams;
|
||||
use codex_app_server_protocol::CancelLoginChatGptParams;
|
||||
use codex_app_server_protocol::ClientInfo;
|
||||
use codex_app_server_protocol::ClientNotification;
|
||||
use codex_app_server_protocol::CollaborationModeListParams;
|
||||
use codex_app_server_protocol::ConfigBatchWriteParams;
|
||||
use codex_app_server_protocol::ConfigReadParams;
|
||||
use codex_app_server_protocol::ConfigValueWriteParams;
|
||||
@@ -396,6 +397,15 @@ impl McpProcess {
|
||||
self.send_request("model/list", params).await
|
||||
}
|
||||
|
||||
/// Send a `collaborationMode/list` JSON-RPC request.
|
||||
pub async fn send_list_collaboration_modes_request(
|
||||
&mut self,
|
||||
params: CollaborationModeListParams,
|
||||
) -> anyhow::Result<i64> {
|
||||
let params = Some(serde_json::to_value(params)?);
|
||||
self.send_request("collaborationMode/list", params).await
|
||||
}
|
||||
|
||||
/// Send a `resumeConversation` JSON-RPC request.
|
||||
pub async fn send_resume_conversation_request(
|
||||
&mut self,
|
||||
|
||||
@@ -57,7 +57,6 @@ pub fn create_fake_rollout(
|
||||
cwd: PathBuf::from("/"),
|
||||
originator: "codex".to_string(),
|
||||
cli_version: "0.0.0".to_string(),
|
||||
instructions: None,
|
||||
source: SessionSource::Cli,
|
||||
model_provider: model_provider.map(str::to_string),
|
||||
};
|
||||
@@ -135,7 +134,6 @@ pub fn create_fake_rollout_with_text_elements(
|
||||
cwd: PathBuf::from("/"),
|
||||
originator: "codex".to_string(),
|
||||
cli_version: "0.0.0".to_string(),
|
||||
instructions: None,
|
||||
source: SessionSource::Cli,
|
||||
model_provider: model_provider.map(str::to_string),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
//! Validates that the collaboration mode list endpoint returns the expected default presets.
|
||||
//!
|
||||
//! The test drives the app server through the MCP harness and asserts that the list response
|
||||
//! includes the plan, pair programming, and execute modes with their default model and reasoning
|
||||
//! effort settings, which keeps the API contract visible in one place.
|
||||
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use app_test_support::McpProcess;
|
||||
use app_test_support::to_response;
|
||||
use codex_app_server_protocol::CollaborationModeListParams;
|
||||
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 pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Confirms the server returns the default collaboration mode presets in a stable order.
|
||||
#[tokio::test]
|
||||
async fn list_collaboration_modes_returns_presets() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_list_collaboration_modes_request(CollaborationModeListParams {})
|
||||
.await?;
|
||||
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let CollaborationModeListResponse { data: items } =
|
||||
to_response::<CollaborationModeListResponse>(response)?;
|
||||
|
||||
let expected = vec![plan_preset(), pair_programming_preset(), execute_preset()];
|
||||
assert_eq!(expected, items);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Builds the plan preset that the list response is expected to return.
|
||||
///
|
||||
/// 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 {
|
||||
let presets = test_builtin_collaboration_mode_presets();
|
||||
presets
|
||||
.into_iter()
|
||||
.find(|p| matches!(p, CollaborationMode::Plan(_)))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Builds the pair programming preset that the list response is expected to return.
|
||||
///
|
||||
/// 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 {
|
||||
let presets = test_builtin_collaboration_mode_presets();
|
||||
presets
|
||||
.into_iter()
|
||||
.find(|p| matches!(p, CollaborationMode::PairProgramming(_)))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Builds the execute preset that the list response is expected to return.
|
||||
///
|
||||
/// The execute preset uses a different reasoning effort to capture the higher-effort
|
||||
/// execution contract the server currently exposes.
|
||||
fn execute_preset() -> CollaborationMode {
|
||||
let presets = test_builtin_collaboration_mode_presets();
|
||||
presets
|
||||
.into_iter()
|
||||
.find(|p| matches!(p, CollaborationMode::Execute(_)))
|
||||
.unwrap()
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
mod account;
|
||||
mod analytics;
|
||||
mod collaboration_mode_list;
|
||||
mod config_rpc;
|
||||
mod initialize;
|
||||
mod model_list;
|
||||
|
||||
@@ -25,6 +25,8 @@ pub enum ApiError {
|
||||
},
|
||||
#[error("rate limit: {0}")]
|
||||
RateLimit(String),
|
||||
#[error("invalid request: {message}")]
|
||||
InvalidRequest { message: String },
|
||||
}
|
||||
|
||||
impl From<RateLimitError> for ApiError {
|
||||
|
||||
@@ -217,6 +217,11 @@ pub fn process_responses_event(
|
||||
response_error = ApiError::QuotaExceeded;
|
||||
} else if is_usage_not_included(&error) {
|
||||
response_error = ApiError::UsageNotIncluded;
|
||||
} else if is_invalid_prompt_error(&error) {
|
||||
let message = error
|
||||
.message
|
||||
.unwrap_or_else(|| "Invalid request.".to_string());
|
||||
response_error = ApiError::InvalidRequest { message };
|
||||
} else {
|
||||
let delay = try_parse_retry_after(&error);
|
||||
let message = error.message.unwrap_or_default();
|
||||
@@ -396,6 +401,10 @@ fn is_usage_not_included(error: &Error) -> bool {
|
||||
error.code.as_deref() == Some("usage_not_included")
|
||||
}
|
||||
|
||||
fn is_invalid_prompt_error(error: &Error) -> bool {
|
||||
error.code.as_deref() == Some("invalid_prompt")
|
||||
}
|
||||
|
||||
fn rate_limit_regex() -> &'static regex_lite::Regex {
|
||||
static RE: std::sync::OnceLock<regex_lite::Regex> = std::sync::OnceLock::new();
|
||||
#[expect(clippy::unwrap_used)]
|
||||
@@ -711,6 +720,27 @@ mod tests {
|
||||
assert_matches!(events[0], Err(ApiError::QuotaExceeded));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_prompt_without_type_is_invalid_request() {
|
||||
let raw_error = r#"{"type":"response.failed","sequence_number":3,"response":{"id":"resp_invalid_prompt_no_type","object":"response","created_at":1759771628,"status":"failed","background":false,"error":{"code":"invalid_prompt","message":"Invalid prompt: we've limited access to this content for safety reasons."},"incomplete_details":null}}"#;
|
||||
|
||||
let sse1 = format!("event: response.failed\ndata: {raw_error}\n\n");
|
||||
|
||||
let events = collect_events(&[sse1.as_bytes()]).await;
|
||||
|
||||
assert_eq!(events.len(), 1);
|
||||
|
||||
match &events[0] {
|
||||
Err(ApiError::InvalidRequest { message }) => {
|
||||
assert_eq!(
|
||||
message,
|
||||
"Invalid prompt: we've limited access to this content for safety reasons."
|
||||
);
|
||||
}
|
||||
other => panic!("unexpected event: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn table_driven_event_kinds() {
|
||||
struct TestCase {
|
||||
|
||||
@@ -18,7 +18,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
arc-swap = "1.7.1"
|
||||
arc-swap = "1.8.0"
|
||||
async-channel = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
|
||||
@@ -81,6 +81,9 @@
|
||||
"collab": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"collaboration_modes": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"elevated_windows_sandbox": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -356,12 +359,12 @@
|
||||
},
|
||||
"shell_environment_policy": {
|
||||
"default": {
|
||||
"exclude": null,
|
||||
"experimental_use_profile": null,
|
||||
"ignore_default_excludes": null,
|
||||
"include_only": null,
|
||||
"inherit": null,
|
||||
"set": null
|
||||
"ignore_default_excludes": null,
|
||||
"exclude": null,
|
||||
"set": null,
|
||||
"include_only": null,
|
||||
"experimental_use_profile": null
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
@@ -557,6 +560,9 @@
|
||||
"collab": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"collaboration_modes": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"elevated_windows_sandbox": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
@@ -28,6 +28,7 @@ pub(crate) fn map_api_error(err: ApiError) -> CodexErr {
|
||||
url: None,
|
||||
request_id: None,
|
||||
}),
|
||||
ApiError::InvalidRequest { message } => CodexErr::InvalidRequest(message),
|
||||
ApiError::Transport(transport) => match transport {
|
||||
TransportError::Http {
|
||||
status,
|
||||
|
||||
@@ -98,6 +98,7 @@ use crate::error::Result as CodexResult;
|
||||
use crate::exec::StreamOutput;
|
||||
use crate::exec_policy::ExecPolicyUpdateError;
|
||||
use crate::feedback_tags;
|
||||
use crate::instructions::UserInstructions;
|
||||
use crate::mcp::auth::compute_auth_statuses;
|
||||
use crate::mcp_connection_manager::McpConnectionManager;
|
||||
use crate::model_provider_info::CHAT_WIRE_API_DEPRECATION_SUMMARY;
|
||||
@@ -155,7 +156,6 @@ use crate::tools::spec::ToolsConfig;
|
||||
use crate::tools::spec::ToolsConfigParams;
|
||||
use crate::turn_diff_tracker::TurnDiffTracker;
|
||||
use crate::unified_exec::UnifiedExecProcessManager;
|
||||
use crate::user_instructions::UserInstructions;
|
||||
use crate::user_notification::UserNotification;
|
||||
use crate::util::backoff;
|
||||
use codex_async_utils::OrCancelExt;
|
||||
@@ -598,12 +598,7 @@ impl Session {
|
||||
let conversation_id = ThreadId::default();
|
||||
(
|
||||
conversation_id,
|
||||
RolloutRecorderParams::new(
|
||||
conversation_id,
|
||||
forked_from_id,
|
||||
session_configuration.user_instructions.clone(),
|
||||
session_source,
|
||||
),
|
||||
RolloutRecorderParams::new(conversation_id, forked_from_id, session_source),
|
||||
)
|
||||
}
|
||||
InitialHistory::Resumed(resumed_history) => (
|
||||
@@ -820,7 +815,7 @@ impl Session {
|
||||
match conversation_history {
|
||||
InitialHistory::New => {
|
||||
// Build and record initial items (user instructions + environment context)
|
||||
let items = self.build_initial_context(&turn_context);
|
||||
let items = self.build_initial_context(&turn_context).await;
|
||||
self.record_conversation_items(&turn_context, &items).await;
|
||||
// Ensure initial items are visible to immediate readers (e.g., tests, forks).
|
||||
self.flush_rollout().await;
|
||||
@@ -858,8 +853,9 @@ impl Session {
|
||||
}
|
||||
|
||||
// Always add response items to conversation history
|
||||
let reconstructed_history =
|
||||
self.reconstruct_history_from_rollout(&turn_context, &rollout_items);
|
||||
let reconstructed_history = self
|
||||
.reconstruct_history_from_rollout(&turn_context, &rollout_items)
|
||||
.await;
|
||||
if !reconstructed_history.is_empty() {
|
||||
self.record_into_history(&reconstructed_history, &turn_context)
|
||||
.await;
|
||||
@@ -878,7 +874,7 @@ impl Session {
|
||||
}
|
||||
|
||||
// Append the current session's initial context after the reconstructed history.
|
||||
let initial_context = self.build_initial_context(&turn_context);
|
||||
let initial_context = self.build_initial_context(&turn_context).await;
|
||||
self.record_conversation_items(&turn_context, &initial_context)
|
||||
.await;
|
||||
// Flush after seeding history and any persisted rollout copy.
|
||||
@@ -1066,6 +1062,50 @@ impl Session {
|
||||
)
|
||||
}
|
||||
|
||||
fn build_collaboration_mode_update_item(
|
||||
&self,
|
||||
previous_collaboration_mode: &CollaborationMode,
|
||||
next_collaboration_mode: Option<&CollaborationMode>,
|
||||
) -> Option<ResponseItem> {
|
||||
if let Some(next_mode) = next_collaboration_mode {
|
||||
if previous_collaboration_mode == next_mode {
|
||||
return None;
|
||||
}
|
||||
// If the next mode has empty developer instructions, this returns None and we emit no
|
||||
// update, so prior collaboration instructions remain in the prompt history.
|
||||
Some(DeveloperInstructions::from_collaboration_mode(next_mode)?.into())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn build_settings_update_items(
|
||||
&self,
|
||||
previous_context: Option<&Arc<TurnContext>>,
|
||||
current_context: &TurnContext,
|
||||
previous_collaboration_mode: &CollaborationMode,
|
||||
next_collaboration_mode: Option<&CollaborationMode>,
|
||||
) -> Vec<ResponseItem> {
|
||||
let mut update_items = Vec::new();
|
||||
if let Some(env_item) =
|
||||
self.build_environment_update_item(previous_context, current_context)
|
||||
{
|
||||
update_items.push(env_item);
|
||||
}
|
||||
if let Some(permissions_item) =
|
||||
self.build_permissions_update_item(previous_context, current_context)
|
||||
{
|
||||
update_items.push(permissions_item);
|
||||
}
|
||||
if let Some(collaboration_mode_item) = self.build_collaboration_mode_update_item(
|
||||
previous_collaboration_mode,
|
||||
next_collaboration_mode,
|
||||
) {
|
||||
update_items.push(collaboration_mode_item);
|
||||
}
|
||||
update_items
|
||||
}
|
||||
|
||||
/// Persist the event to rollout and send it to clients.
|
||||
pub(crate) async fn send_event(&self, turn_context: &TurnContext, msg: EventMsg) {
|
||||
let legacy_source = msg.clone();
|
||||
@@ -1304,7 +1344,7 @@ impl Session {
|
||||
self.send_raw_response_items(turn_context, items).await;
|
||||
}
|
||||
|
||||
fn reconstruct_history_from_rollout(
|
||||
async fn reconstruct_history_from_rollout(
|
||||
&self,
|
||||
turn_context: &TurnContext,
|
||||
rollout_items: &[RolloutItem],
|
||||
@@ -1324,7 +1364,7 @@ impl Session {
|
||||
} else {
|
||||
let user_messages = collect_user_messages(history.raw_items());
|
||||
let rebuilt = compact::build_compacted_history(
|
||||
self.build_initial_context(turn_context),
|
||||
self.build_initial_context(turn_context).await,
|
||||
&user_messages,
|
||||
&compacted.message,
|
||||
);
|
||||
@@ -1394,7 +1434,10 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_initial_context(&self, turn_context: &TurnContext) -> Vec<ResponseItem> {
|
||||
pub(crate) async fn build_initial_context(
|
||||
&self,
|
||||
turn_context: &TurnContext,
|
||||
) -> Vec<ResponseItem> {
|
||||
let mut items = Vec::<ResponseItem>::with_capacity(4);
|
||||
let shell = self.user_shell();
|
||||
items.push(
|
||||
@@ -1408,6 +1451,16 @@ impl Session {
|
||||
if let Some(developer_instructions) = turn_context.developer_instructions.as_deref() {
|
||||
items.push(DeveloperInstructions::new(developer_instructions.to_string()).into());
|
||||
}
|
||||
// Add developer instructions from collaboration_mode if they exist and are non-empty
|
||||
let collaboration_mode = {
|
||||
let state = self.state.lock().await;
|
||||
state.session_configuration.collaboration_mode.clone()
|
||||
};
|
||||
if let Some(collab_instructions) =
|
||||
DeveloperInstructions::from_collaboration_mode(&collaboration_mode)
|
||||
{
|
||||
items.push(collab_instructions.into());
|
||||
}
|
||||
if let Some(user_instructions) = turn_context.user_instructions.as_deref() {
|
||||
items.push(
|
||||
UserInstructions {
|
||||
@@ -1989,6 +2042,18 @@ mod handlers {
|
||||
sub_id: String,
|
||||
updates: SessionSettingsUpdate,
|
||||
) {
|
||||
let previous_context = sess
|
||||
.new_default_turn_with_sub_id(sess.next_internal_sub_id())
|
||||
.await;
|
||||
let previous_collaboration_mode = sess
|
||||
.state
|
||||
.lock()
|
||||
.await
|
||||
.session_configuration
|
||||
.collaboration_mode
|
||||
.clone();
|
||||
let next_collaboration_mode = updates.collaboration_mode.clone();
|
||||
|
||||
if let Err(err) = sess.update_settings(updates).await {
|
||||
sess.send_event_raw(Event {
|
||||
id: sub_id,
|
||||
@@ -1998,6 +2063,19 @@ mod handlers {
|
||||
}),
|
||||
})
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
let current_context = sess.new_default_turn_with_sub_id(sub_id).await;
|
||||
let update_items = sess.build_settings_update_items(
|
||||
Some(&previous_context),
|
||||
¤t_context,
|
||||
&previous_collaboration_mode,
|
||||
next_collaboration_mode.as_ref(),
|
||||
);
|
||||
if !update_items.is_empty() {
|
||||
sess.record_conversation_items(¤t_context, &update_items)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2051,6 +2129,14 @@ mod handlers {
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
let previous_collaboration_mode = sess
|
||||
.state
|
||||
.lock()
|
||||
.await
|
||||
.session_configuration
|
||||
.collaboration_mode
|
||||
.clone();
|
||||
let next_collaboration_mode = updates.collaboration_mode.clone();
|
||||
let Ok(current_context) = sess.new_turn_with_sub_id(sub_id, updates).await else {
|
||||
// new_turn_with_sub_id already emits the error event.
|
||||
return;
|
||||
@@ -2062,17 +2148,12 @@ mod handlers {
|
||||
|
||||
// Attempt to inject input into current task
|
||||
if let Err(items) = sess.inject_input(items).await {
|
||||
let mut update_items = Vec::new();
|
||||
if let Some(env_item) =
|
||||
sess.build_environment_update_item(previous_context.as_ref(), ¤t_context)
|
||||
{
|
||||
update_items.push(env_item);
|
||||
}
|
||||
if let Some(permissions_item) =
|
||||
sess.build_permissions_update_item(previous_context.as_ref(), ¤t_context)
|
||||
{
|
||||
update_items.push(permissions_item);
|
||||
}
|
||||
let update_items = sess.build_settings_update_items(
|
||||
previous_context.as_ref(),
|
||||
¤t_context,
|
||||
&previous_collaboration_mode,
|
||||
next_collaboration_mode.as_ref(),
|
||||
);
|
||||
if !update_items.is_empty() {
|
||||
sess.record_conversation_items(¤t_context, &update_items)
|
||||
.await;
|
||||
@@ -3183,9 +3264,11 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn reconstruct_history_matches_live_compactions() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
let (rollout_items, expected) = sample_rollout(&session, &turn_context);
|
||||
let (rollout_items, expected) = sample_rollout(&session, &turn_context).await;
|
||||
|
||||
let reconstructed = session.reconstruct_history_from_rollout(&turn_context, &rollout_items);
|
||||
let reconstructed = session
|
||||
.reconstruct_history_from_rollout(&turn_context, &rollout_items)
|
||||
.await;
|
||||
|
||||
assert_eq!(expected, reconstructed);
|
||||
}
|
||||
@@ -3193,7 +3276,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn record_initial_history_reconstructs_resumed_transcript() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
let (rollout_items, mut expected) = sample_rollout(&session, &turn_context);
|
||||
let (rollout_items, mut expected) = sample_rollout(&session, &turn_context).await;
|
||||
|
||||
session
|
||||
.record_initial_history(InitialHistory::Resumed(ResumedHistory {
|
||||
@@ -3203,7 +3286,7 @@ mod tests {
|
||||
}))
|
||||
.await;
|
||||
|
||||
expected.extend(session.build_initial_context(&turn_context));
|
||||
expected.extend(session.build_initial_context(&turn_context).await);
|
||||
let history = session.state.lock().await.clone_history();
|
||||
assert_eq!(expected, history.raw_items());
|
||||
}
|
||||
@@ -3211,7 +3294,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn record_initial_history_seeds_token_info_from_rollout() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
let (mut rollout_items, _expected) = sample_rollout(&session, &turn_context);
|
||||
let (mut rollout_items, _expected) = sample_rollout(&session, &turn_context).await;
|
||||
|
||||
let info1 = TokenUsageInfo {
|
||||
total_token_usage: TokenUsage {
|
||||
@@ -3288,13 +3371,13 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn record_initial_history_reconstructs_forked_transcript() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
let (rollout_items, mut expected) = sample_rollout(&session, &turn_context);
|
||||
let (rollout_items, mut expected) = sample_rollout(&session, &turn_context).await;
|
||||
|
||||
session
|
||||
.record_initial_history(InitialHistory::Forked(rollout_items))
|
||||
.await;
|
||||
|
||||
expected.extend(session.build_initial_context(&turn_context));
|
||||
expected.extend(session.build_initial_context(&turn_context).await);
|
||||
let history = session.state.lock().await.clone_history();
|
||||
assert_eq!(expected, history.raw_items());
|
||||
}
|
||||
@@ -3303,7 +3386,7 @@ mod tests {
|
||||
async fn thread_rollback_drops_last_turn_from_history() {
|
||||
let (sess, tc, rx) = make_session_and_context_with_rx().await;
|
||||
|
||||
let initial_context = sess.build_initial_context(tc.as_ref());
|
||||
let initial_context = sess.build_initial_context(tc.as_ref()).await;
|
||||
sess.record_into_history(&initial_context, tc.as_ref())
|
||||
.await;
|
||||
|
||||
@@ -3360,7 +3443,7 @@ mod tests {
|
||||
async fn thread_rollback_clears_history_when_num_turns_exceeds_existing_turns() {
|
||||
let (sess, tc, rx) = make_session_and_context_with_rx().await;
|
||||
|
||||
let initial_context = sess.build_initial_context(tc.as_ref());
|
||||
let initial_context = sess.build_initial_context(tc.as_ref()).await;
|
||||
sess.record_into_history(&initial_context, tc.as_ref())
|
||||
.await;
|
||||
|
||||
@@ -3386,7 +3469,7 @@ mod tests {
|
||||
async fn thread_rollback_fails_when_turn_in_progress() {
|
||||
let (sess, tc, rx) = make_session_and_context_with_rx().await;
|
||||
|
||||
let initial_context = sess.build_initial_context(tc.as_ref());
|
||||
let initial_context = sess.build_initial_context(tc.as_ref()).await;
|
||||
sess.record_into_history(&initial_context, tc.as_ref())
|
||||
.await;
|
||||
|
||||
@@ -3407,7 +3490,7 @@ mod tests {
|
||||
async fn thread_rollback_fails_when_num_turns_is_zero() {
|
||||
let (sess, tc, rx) = make_session_and_context_with_rx().await;
|
||||
|
||||
let initial_context = sess.build_initial_context(tc.as_ref());
|
||||
let initial_context = sess.build_initial_context(tc.as_ref()).await;
|
||||
sess.record_into_history(&initial_context, tc.as_ref())
|
||||
.await;
|
||||
|
||||
@@ -4193,14 +4276,14 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_rollout(
|
||||
async fn sample_rollout(
|
||||
session: &Session,
|
||||
turn_context: &TurnContext,
|
||||
) -> (Vec<RolloutItem>, Vec<ResponseItem>) {
|
||||
let mut rollout_items = Vec::new();
|
||||
let mut live_history = ContextManager::new();
|
||||
|
||||
let initial_context = session.build_initial_context(turn_context);
|
||||
let initial_context = session.build_initial_context(turn_context).await;
|
||||
for item in &initial_context {
|
||||
rollout_items.push(RolloutItem::ResponseItem(item.clone()));
|
||||
}
|
||||
@@ -4230,7 +4313,7 @@ mod tests {
|
||||
let snapshot1 = live_history.clone().for_prompt();
|
||||
let user_messages1 = collect_user_messages(&snapshot1);
|
||||
let rebuilt1 = compact::build_compacted_history(
|
||||
session.build_initial_context(turn_context),
|
||||
session.build_initial_context(turn_context).await,
|
||||
&user_messages1,
|
||||
summary1,
|
||||
);
|
||||
@@ -4264,7 +4347,7 @@ mod tests {
|
||||
let snapshot2 = live_history.clone().for_prompt();
|
||||
let user_messages2 = collect_user_messages(&snapshot2);
|
||||
let rebuilt2 = compact::build_compacted_history(
|
||||
session.build_initial_context(turn_context),
|
||||
session.build_initial_context(turn_context).await,
|
||||
&user_messages2,
|
||||
summary2,
|
||||
);
|
||||
@@ -4282,7 +4365,7 @@ mod tests {
|
||||
}],
|
||||
};
|
||||
live_history.record_items(std::iter::once(&user3), turn_context.truncation_policy);
|
||||
rollout_items.push(RolloutItem::ResponseItem(user3.clone()));
|
||||
rollout_items.push(RolloutItem::ResponseItem(user3));
|
||||
|
||||
let assistant3 = ResponseItem::Message {
|
||||
id: None,
|
||||
@@ -4292,7 +4375,7 @@ mod tests {
|
||||
}],
|
||||
};
|
||||
live_history.record_items(std::iter::once(&assistant3), turn_context.truncation_policy);
|
||||
rollout_items.push(RolloutItem::ResponseItem(assistant3.clone()));
|
||||
rollout_items.push(RolloutItem::ResponseItem(assistant3));
|
||||
|
||||
(rollout_items, live_history.for_prompt())
|
||||
}
|
||||
|
||||
@@ -167,7 +167,7 @@ async fn run_compact_task_inner(
|
||||
let summary_text = format!("{SUMMARY_PREFIX}\n{summary_suffix}");
|
||||
let user_messages = collect_user_messages(history_items);
|
||||
|
||||
let initial_context = sess.build_initial_context(turn_context.as_ref());
|
||||
let initial_context = sess.build_initial_context(turn_context.as_ref()).await;
|
||||
let mut new_history = build_compacted_history(initial_context, &user_messages, &summary_text);
|
||||
let ghost_snapshots: Vec<ResponseItem> = history_items
|
||||
.iter()
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
use crate::config::CONFIG_TOML_FILE;
|
||||
use crate::config::types::McpServerConfig;
|
||||
use crate::config::types::Notice;
|
||||
use crate::path_utils::resolve_symlink_write_paths;
|
||||
use crate::path_utils::write_atomically;
|
||||
use anyhow::Context;
|
||||
use codex_protocol::config_types::TrustLevel;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::NamedTempFile;
|
||||
use tokio::task;
|
||||
use toml_edit::ArrayOfTables;
|
||||
use toml_edit::DocumentMut;
|
||||
@@ -625,10 +626,14 @@ pub fn apply_blocking(
|
||||
}
|
||||
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
let serialized = match std::fs::read_to_string(&config_path) {
|
||||
Ok(contents) => contents,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(),
|
||||
Err(err) => return Err(err.into()),
|
||||
let write_paths = resolve_symlink_write_paths(&config_path)?;
|
||||
let serialized = match write_paths.read_path {
|
||||
Some(path) => match std::fs::read_to_string(&path) {
|
||||
Ok(contents) => contents,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(),
|
||||
Err(err) => return Err(err.into()),
|
||||
},
|
||||
None => String::new(),
|
||||
};
|
||||
|
||||
let doc = if serialized.is_empty() {
|
||||
@@ -654,22 +659,13 @@ pub fn apply_blocking(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
std::fs::create_dir_all(codex_home).with_context(|| {
|
||||
write_atomically(&write_paths.write_path, &document.doc.to_string()).with_context(|| {
|
||||
format!(
|
||||
"failed to create Codex home directory at {}",
|
||||
codex_home.display()
|
||||
"failed to persist config.toml at {}",
|
||||
write_paths.write_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
let tmp = NamedTempFile::new_in(codex_home)?;
|
||||
std::fs::write(tmp.path(), document.doc.to_string()).with_context(|| {
|
||||
format!(
|
||||
"failed to write temporary config file at {}",
|
||||
tmp.path().display()
|
||||
)
|
||||
})?;
|
||||
tmp.persist(config_path)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -813,6 +809,8 @@ mod tests {
|
||||
use crate::config::types::McpServerTransportConfig;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use pretty_assertions::assert_eq;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::symlink;
|
||||
use tempfile::tempdir;
|
||||
use toml::Value as TomlValue;
|
||||
|
||||
@@ -952,6 +950,71 @@ profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } }
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn blocking_set_model_writes_through_symlink_chain() {
|
||||
let tmp = tempdir().expect("tmpdir");
|
||||
let codex_home = tmp.path();
|
||||
let target_dir = tempdir().expect("target dir");
|
||||
let target_path = target_dir.path().join(CONFIG_TOML_FILE);
|
||||
let link_path = codex_home.join("config-link.toml");
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
|
||||
symlink(&target_path, &link_path).expect("symlink link");
|
||||
symlink("config-link.toml", &config_path).expect("symlink config");
|
||||
|
||||
apply_blocking(
|
||||
codex_home,
|
||||
None,
|
||||
&[ConfigEdit::SetModel {
|
||||
model: Some("gpt-5.1-codex".to_string()),
|
||||
effort: Some(ReasoningEffort::High),
|
||||
}],
|
||||
)
|
||||
.expect("persist");
|
||||
|
||||
let meta = std::fs::symlink_metadata(&config_path).expect("config metadata");
|
||||
assert!(meta.file_type().is_symlink());
|
||||
|
||||
let contents = std::fs::read_to_string(&target_path).expect("read target");
|
||||
let expected = r#"model = "gpt-5.1-codex"
|
||||
model_reasoning_effort = "high"
|
||||
"#;
|
||||
assert_eq!(contents, expected);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn blocking_set_model_replaces_symlink_on_cycle() {
|
||||
let tmp = tempdir().expect("tmpdir");
|
||||
let codex_home = tmp.path();
|
||||
let link_a = codex_home.join("a.toml");
|
||||
let link_b = codex_home.join("b.toml");
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
|
||||
symlink("b.toml", &link_a).expect("symlink a");
|
||||
symlink("a.toml", &link_b).expect("symlink b");
|
||||
symlink("a.toml", &config_path).expect("symlink config");
|
||||
|
||||
apply_blocking(
|
||||
codex_home,
|
||||
None,
|
||||
&[ConfigEdit::SetModel {
|
||||
model: Some("gpt-5.1-codex".to_string()),
|
||||
effort: None,
|
||||
}],
|
||||
)
|
||||
.expect("persist");
|
||||
|
||||
let meta = std::fs::symlink_metadata(&config_path).expect("config metadata");
|
||||
assert!(!meta.file_type().is_symlink());
|
||||
|
||||
let contents = std::fs::read_to_string(&config_path).expect("read config");
|
||||
let expected = r#"model = "gpt-5.1-codex"
|
||||
"#;
|
||||
assert_eq!(contents, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_write_table_upsert_preserves_inline_comments() {
|
||||
let tmp = tempdir().expect("tmpdir");
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use crate::auth::AuthCredentialsStoreMode;
|
||||
use crate::config::edit::ConfigEdit;
|
||||
use crate::config::edit::ConfigEditsBuilder;
|
||||
use crate::config::types::DEFAULT_OTEL_ENVIRONMENT;
|
||||
use crate::config::types::History;
|
||||
use crate::config::types::McpServerConfig;
|
||||
@@ -751,30 +753,17 @@ pub fn set_default_oss_provider(codex_home: &Path, provider: &str) -> std::io::R
|
||||
));
|
||||
}
|
||||
}
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
|
||||
// Read existing config or create empty string if file doesn't exist
|
||||
let content = match std::fs::read_to_string(&config_path) {
|
||||
Ok(content) => content,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
|
||||
// Parse as DocumentMut for editing while preserving structure
|
||||
let mut doc = content.parse::<DocumentMut>().map_err(|e| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("failed to parse config.toml: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Set the default_oss_provider at root level
|
||||
use toml_edit::value;
|
||||
doc["oss_provider"] = value(provider);
|
||||
|
||||
// Write the modified document back
|
||||
std::fs::write(&config_path, doc.to_string())?;
|
||||
Ok(())
|
||||
let edits = [ConfigEdit::SetPath {
|
||||
segments: vec!["oss_provider".to_string()],
|
||||
value: value(provider),
|
||||
}];
|
||||
|
||||
ConfigEditsBuilder::new(codex_home)
|
||||
.with_edits(edits)
|
||||
.apply_blocking()
|
||||
.map_err(|err| std::io::Error::other(format!("failed to persist config.toml: {err}")))
|
||||
}
|
||||
|
||||
/// Base config deserialized from ~/.codex/config.toml.
|
||||
|
||||
@@ -9,6 +9,9 @@ use crate::config_loader::LoaderOverrides;
|
||||
use crate::config_loader::load_config_layers_state;
|
||||
use crate::config_loader::merge_toml_values;
|
||||
use crate::path_utils;
|
||||
use crate::path_utils::SymlinkWritePaths;
|
||||
use crate::path_utils::resolve_symlink_write_paths;
|
||||
use crate::path_utils::write_atomically;
|
||||
use codex_app_server_protocol::Config as ApiConfig;
|
||||
use codex_app_server_protocol::ConfigBatchWriteParams;
|
||||
use codex_app_server_protocol::ConfigLayerMetadata;
|
||||
@@ -27,6 +30,7 @@ use std::borrow::Cow;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use thiserror::Error;
|
||||
use tokio::task;
|
||||
use toml::Value as TomlValue;
|
||||
use toml_edit::Item as TomlItem;
|
||||
|
||||
@@ -362,19 +366,30 @@ impl ConfigService {
|
||||
async fn create_empty_user_layer(
|
||||
config_toml: &AbsolutePathBuf,
|
||||
) -> Result<ConfigLayerEntry, ConfigServiceError> {
|
||||
let toml_value = match tokio::fs::read_to_string(config_toml).await {
|
||||
Ok(contents) => toml::from_str(&contents).map_err(|e| {
|
||||
ConfigServiceError::toml("failed to parse existing user config.toml", e)
|
||||
})?,
|
||||
Err(e) => {
|
||||
if e.kind() == std::io::ErrorKind::NotFound {
|
||||
tokio::fs::write(config_toml, "").await.map_err(|e| {
|
||||
ConfigServiceError::io("failed to create empty user config.toml", e)
|
||||
})?;
|
||||
let SymlinkWritePaths {
|
||||
read_path,
|
||||
write_path,
|
||||
} = resolve_symlink_write_paths(config_toml.as_path())
|
||||
.map_err(|err| ConfigServiceError::io("failed to resolve user config path", err))?;
|
||||
let toml_value = match read_path {
|
||||
Some(path) => match tokio::fs::read_to_string(&path).await {
|
||||
Ok(contents) => toml::from_str(&contents).map_err(|e| {
|
||||
ConfigServiceError::toml("failed to parse existing user config.toml", e)
|
||||
})?,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||
write_empty_user_config(write_path.clone()).await?;
|
||||
TomlValue::Table(toml::map::Map::new())
|
||||
} else {
|
||||
return Err(ConfigServiceError::io("failed to read user config.toml", e));
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(ConfigServiceError::io(
|
||||
"failed to read user config.toml",
|
||||
err,
|
||||
));
|
||||
}
|
||||
},
|
||||
None => {
|
||||
write_empty_user_config(write_path).await?;
|
||||
TomlValue::Table(toml::map::Map::new())
|
||||
}
|
||||
};
|
||||
Ok(ConfigLayerEntry::new(
|
||||
@@ -385,6 +400,13 @@ async fn create_empty_user_layer(
|
||||
))
|
||||
}
|
||||
|
||||
async fn write_empty_user_config(write_path: PathBuf) -> Result<(), ConfigServiceError> {
|
||||
task::spawn_blocking(move || write_atomically(&write_path, ""))
|
||||
.await
|
||||
.map_err(|err| ConfigServiceError::anyhow("config persistence task panicked", err.into()))?
|
||||
.map_err(|err| ConfigServiceError::io("failed to create empty user config.toml", err))
|
||||
}
|
||||
|
||||
fn parse_value(value: JsonValue) -> Result<Option<TomlValue>, String> {
|
||||
if value.is_null() {
|
||||
return Ok(None);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use crate::codex::TurnContext;
|
||||
use crate::context_manager::normalize;
|
||||
use crate::instructions::SkillInstructions;
|
||||
use crate::instructions::UserInstructions;
|
||||
use crate::truncate::TruncationPolicy;
|
||||
use crate::truncate::approx_token_count;
|
||||
use crate::truncate::approx_tokens_from_byte_count;
|
||||
use crate::truncate::truncate_function_output_items_with_policy;
|
||||
use crate::truncate::truncate_text;
|
||||
use crate::user_instructions::SkillInstructions;
|
||||
use crate::user_instructions::UserInstructions;
|
||||
use crate::user_shell_command::is_user_shell_command_text;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputContentItem;
|
||||
|
||||
@@ -17,8 +17,8 @@ use codex_protocol::user_input::UserInput;
|
||||
use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::user_instructions::SkillInstructions;
|
||||
use crate::user_instructions::UserInstructions;
|
||||
use crate::instructions::SkillInstructions;
|
||||
use crate::instructions::UserInstructions;
|
||||
use crate::user_shell_command::is_user_shell_command_text;
|
||||
|
||||
fn is_session_prefix(text: &str) -> bool {
|
||||
|
||||
@@ -100,6 +100,8 @@ pub enum Feature {
|
||||
Collab,
|
||||
/// Steer feature flag - when enabled, Enter submits immediately instead of queuing.
|
||||
Steer,
|
||||
/// Enable collaboration modes (Plan, Pair Programming, Execute).
|
||||
CollaborationModes,
|
||||
}
|
||||
|
||||
impl Feature {
|
||||
@@ -444,4 +446,10 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
},
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::CollaborationModes,
|
||||
key: "collaboration_modes",
|
||||
stage: Stage::Experimental,
|
||||
default_enabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
6
codex-rs/core/src/instructions/mod.rs
Normal file
6
codex-rs/core/src/instructions/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
mod user_instructions;
|
||||
|
||||
pub(crate) use user_instructions::SkillInstructions;
|
||||
pub use user_instructions::USER_INSTRUCTIONS_OPEN_TAG_LEGACY;
|
||||
pub use user_instructions::USER_INSTRUCTIONS_PREFIX;
|
||||
pub(crate) use user_instructions::UserInstructions;
|
||||
@@ -31,6 +31,7 @@ mod exec_policy;
|
||||
pub mod features;
|
||||
mod flags;
|
||||
pub mod git_info;
|
||||
pub mod instructions;
|
||||
pub mod landlock;
|
||||
pub mod mcp;
|
||||
mod mcp_connection_manager;
|
||||
@@ -50,7 +51,6 @@ mod text_encoding;
|
||||
pub mod token_data;
|
||||
mod truncate;
|
||||
mod unified_exec;
|
||||
mod user_instructions;
|
||||
pub mod windows_sandbox;
|
||||
pub use model_provider_info::CHAT_WIRE_API_DEPRECATION_SUMMARY;
|
||||
pub use model_provider_info::DEFAULT_LMSTUDIO_PORT;
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::config_types::Settings;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
|
||||
const COLLABORATION_MODE_PLAN: &str = include_str!("../../templates/collaboration_mode/plan.md");
|
||||
const COLLABORATION_MODE_PAIR_PROGRAMMING: &str =
|
||||
include_str!("../../templates/collaboration_mode/pair_programming.md");
|
||||
const COLLABORATION_MODE_EXECUTE: &str =
|
||||
include_str!("../../templates/collaboration_mode/execute.md");
|
||||
|
||||
pub(super) fn builtin_collaboration_mode_presets() -> Vec<CollaborationMode> {
|
||||
vec![plan_preset(), pair_programming_preset(), execute_preset()]
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn test_builtin_collaboration_mode_presets() -> Vec<CollaborationMode> {
|
||||
builtin_collaboration_mode_presets()
|
||||
}
|
||||
|
||||
fn plan_preset() -> CollaborationMode {
|
||||
CollaborationMode::Plan(Settings {
|
||||
model: "gpt-5.2-codex".to_string(),
|
||||
reasoning_effort: Some(ReasoningEffort::Medium),
|
||||
developer_instructions: Some(COLLABORATION_MODE_PLAN.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
fn pair_programming_preset() -> CollaborationMode {
|
||||
CollaborationMode::PairProgramming(Settings {
|
||||
model: "gpt-5.2-codex".to_string(),
|
||||
reasoning_effort: Some(ReasoningEffort::Medium),
|
||||
developer_instructions: Some(COLLABORATION_MODE_PAIR_PROGRAMMING.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
fn execute_preset() -> CollaborationMode {
|
||||
CollaborationMode::Execute(Settings {
|
||||
model: "gpt-5.2-codex".to_string(),
|
||||
reasoning_effort: Some(ReasoningEffort::XHigh),
|
||||
developer_instructions: Some(COLLABORATION_MODE_EXECUTE.to_string()),
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
use codex_api::ModelsClient;
|
||||
use codex_api::ReqwestTransport;
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::openai_models::ModelPreset;
|
||||
use codex_protocol::openai_models::ModelsResponse;
|
||||
@@ -23,6 +24,7 @@ use crate::error::CodexErr;
|
||||
use crate::error::Result as CoreResult;
|
||||
use crate::features::Feature;
|
||||
use crate::model_provider_info::ModelProviderInfo;
|
||||
use crate::models_manager::collaboration_mode_presets::builtin_collaboration_mode_presets;
|
||||
use crate::models_manager::model_info;
|
||||
use crate::models_manager::model_presets::builtin_model_presets;
|
||||
|
||||
@@ -90,6 +92,13 @@ impl ModelsManager {
|
||||
self.build_available_models(remote_models)
|
||||
}
|
||||
|
||||
/// List collaboration mode presets.
|
||||
///
|
||||
/// Returns a static set of presets seeded with the configured model.
|
||||
pub fn list_collaboration_modes(&self) -> Vec<CollaborationMode> {
|
||||
builtin_collaboration_mode_presets()
|
||||
}
|
||||
|
||||
/// Attempt to list models without blocking, using the current cached state.
|
||||
///
|
||||
/// Returns an error if the internal lock cannot be acquired.
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
pub mod cache;
|
||||
pub mod collaboration_mode_presets;
|
||||
pub mod manager;
|
||||
pub mod model_info;
|
||||
pub mod model_presets;
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub use collaboration_mode_presets::test_builtin_collaboration_mode_presets;
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use std::collections::HashSet;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
use crate::env;
|
||||
|
||||
@@ -8,6 +12,106 @@ pub fn normalize_for_path_comparison(path: impl AsRef<Path>) -> std::io::Result<
|
||||
Ok(normalize_for_wsl(canonical))
|
||||
}
|
||||
|
||||
pub struct SymlinkWritePaths {
|
||||
pub read_path: Option<PathBuf>,
|
||||
pub write_path: PathBuf,
|
||||
}
|
||||
|
||||
/// Resolve the final filesystem target for `path` while retaining a safe write path.
|
||||
///
|
||||
/// This follows symlink chains (including relative symlink targets) until it reaches a
|
||||
/// non-symlink path. If the chain cycles or any metadata/link resolution fails, it
|
||||
/// returns `read_path: None` and uses the original absolute path as `write_path`.
|
||||
/// There is no fixed max-resolution count; cycles are detected via a visited set.
|
||||
pub fn resolve_symlink_write_paths(path: &Path) -> io::Result<SymlinkWritePaths> {
|
||||
let root = AbsolutePathBuf::from_absolute_path(path)
|
||||
.map(AbsolutePathBuf::into_path_buf)
|
||||
.unwrap_or_else(|_| path.to_path_buf());
|
||||
let mut current = root.clone();
|
||||
let mut visited = HashSet::new();
|
||||
|
||||
// Follow symlink chains while guarding against cycles.
|
||||
loop {
|
||||
let meta = match std::fs::symlink_metadata(¤t) {
|
||||
Ok(meta) => meta,
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => {
|
||||
return Ok(SymlinkWritePaths {
|
||||
read_path: Some(current.clone()),
|
||||
write_path: current,
|
||||
});
|
||||
}
|
||||
Err(_) => {
|
||||
return Ok(SymlinkWritePaths {
|
||||
read_path: None,
|
||||
write_path: root,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if !meta.file_type().is_symlink() {
|
||||
return Ok(SymlinkWritePaths {
|
||||
read_path: Some(current.clone()),
|
||||
write_path: current,
|
||||
});
|
||||
}
|
||||
|
||||
// If we've already seen this path, the chain cycles.
|
||||
if !visited.insert(current.clone()) {
|
||||
return Ok(SymlinkWritePaths {
|
||||
read_path: None,
|
||||
write_path: root,
|
||||
});
|
||||
}
|
||||
|
||||
let target = match std::fs::read_link(¤t) {
|
||||
Ok(target) => target,
|
||||
Err(_) => {
|
||||
return Ok(SymlinkWritePaths {
|
||||
read_path: None,
|
||||
write_path: root,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let next = if target.is_absolute() {
|
||||
AbsolutePathBuf::from_absolute_path(&target)
|
||||
} else if let Some(parent) = current.parent() {
|
||||
AbsolutePathBuf::resolve_path_against_base(&target, parent)
|
||||
} else {
|
||||
return Ok(SymlinkWritePaths {
|
||||
read_path: None,
|
||||
write_path: root,
|
||||
});
|
||||
};
|
||||
|
||||
let next = match next {
|
||||
Ok(path) => path.into_path_buf(),
|
||||
Err(_) => {
|
||||
return Ok(SymlinkWritePaths {
|
||||
read_path: None,
|
||||
write_path: root,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
current = next;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write_atomically(write_path: &Path, contents: &str) -> io::Result<()> {
|
||||
let parent = write_path.parent().ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("path {} has no parent directory", write_path.display()),
|
||||
)
|
||||
})?;
|
||||
std::fs::create_dir_all(parent)?;
|
||||
let tmp = NamedTempFile::new_in(parent)?;
|
||||
std::fs::write(tmp.path(), contents)?;
|
||||
tmp.persist(write_path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalize_for_wsl(path: PathBuf) -> PathBuf {
|
||||
normalize_for_wsl_with_flag(path, env::is_wsl())
|
||||
}
|
||||
@@ -84,6 +188,29 @@ fn lower_ascii_path(path: PathBuf) -> PathBuf {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[cfg(unix)]
|
||||
mod symlinks {
|
||||
use super::super::resolve_symlink_write_paths;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
#[test]
|
||||
fn symlink_cycles_fall_back_to_root_write_path() -> std::io::Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let a = dir.path().join("a");
|
||||
let b = dir.path().join("b");
|
||||
|
||||
symlink(&b, &a)?;
|
||||
symlink(&a, &b)?;
|
||||
|
||||
let resolved = resolve_symlink_write_paths(&a)?;
|
||||
|
||||
assert_eq!(resolved.read_path, None);
|
||||
assert_eq!(resolved.write_path, a);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod wsl {
|
||||
use super::super::normalize_for_wsl_with_flag;
|
||||
|
||||
@@ -58,7 +58,6 @@ pub enum RolloutRecorderParams {
|
||||
Create {
|
||||
conversation_id: ThreadId,
|
||||
forked_from_id: Option<ThreadId>,
|
||||
instructions: Option<String>,
|
||||
source: SessionSource,
|
||||
},
|
||||
Resume {
|
||||
@@ -81,13 +80,11 @@ impl RolloutRecorderParams {
|
||||
pub fn new(
|
||||
conversation_id: ThreadId,
|
||||
forked_from_id: Option<ThreadId>,
|
||||
instructions: Option<String>,
|
||||
source: SessionSource,
|
||||
) -> Self {
|
||||
Self::Create {
|
||||
conversation_id,
|
||||
forked_from_id,
|
||||
instructions,
|
||||
source,
|
||||
}
|
||||
}
|
||||
@@ -162,7 +159,6 @@ impl RolloutRecorder {
|
||||
RolloutRecorderParams::Create {
|
||||
conversation_id,
|
||||
forked_from_id,
|
||||
instructions,
|
||||
source,
|
||||
} => {
|
||||
let LogFileInfo {
|
||||
@@ -190,7 +186,6 @@ impl RolloutRecorder {
|
||||
cwd: config.cwd.clone(),
|
||||
originator: originator().value,
|
||||
cli_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
instructions,
|
||||
source,
|
||||
model_provider: Some(config.model_provider_id.clone()),
|
||||
}),
|
||||
|
||||
@@ -86,7 +86,6 @@ fn write_session_file_with_provider(
|
||||
let mut payload = serde_json::json!({
|
||||
"id": uuid,
|
||||
"timestamp": ts_str,
|
||||
"instructions": null,
|
||||
"cwd": ".",
|
||||
"originator": "test_originator",
|
||||
"cli_version": "test_version",
|
||||
@@ -202,7 +201,6 @@ async fn test_list_conversations_latest_first() {
|
||||
let head_3 = vec![serde_json::json!({
|
||||
"id": u3,
|
||||
"timestamp": "2025-01-03T12-00-00",
|
||||
"instructions": null,
|
||||
"cwd": ".",
|
||||
"originator": "test_originator",
|
||||
"cli_version": "test_version",
|
||||
@@ -212,7 +210,6 @@ async fn test_list_conversations_latest_first() {
|
||||
let head_2 = vec![serde_json::json!({
|
||||
"id": u2,
|
||||
"timestamp": "2025-01-02T12-00-00",
|
||||
"instructions": null,
|
||||
"cwd": ".",
|
||||
"originator": "test_originator",
|
||||
"cli_version": "test_version",
|
||||
@@ -222,7 +219,6 @@ async fn test_list_conversations_latest_first() {
|
||||
let head_1 = vec![serde_json::json!({
|
||||
"id": u1,
|
||||
"timestamp": "2025-01-01T12-00-00",
|
||||
"instructions": null,
|
||||
"cwd": ".",
|
||||
"originator": "test_originator",
|
||||
"cli_version": "test_version",
|
||||
@@ -343,7 +339,6 @@ async fn test_pagination_cursor() {
|
||||
let head_5 = vec![serde_json::json!({
|
||||
"id": u5,
|
||||
"timestamp": "2025-03-05T09-00-00",
|
||||
"instructions": null,
|
||||
"cwd": ".",
|
||||
"originator": "test_originator",
|
||||
"cli_version": "test_version",
|
||||
@@ -353,7 +348,6 @@ async fn test_pagination_cursor() {
|
||||
let head_4 = vec![serde_json::json!({
|
||||
"id": u4,
|
||||
"timestamp": "2025-03-04T09-00-00",
|
||||
"instructions": null,
|
||||
"cwd": ".",
|
||||
"originator": "test_originator",
|
||||
"cli_version": "test_version",
|
||||
@@ -411,7 +405,6 @@ async fn test_pagination_cursor() {
|
||||
let head_3 = vec![serde_json::json!({
|
||||
"id": u3,
|
||||
"timestamp": "2025-03-03T09-00-00",
|
||||
"instructions": null,
|
||||
"cwd": ".",
|
||||
"originator": "test_originator",
|
||||
"cli_version": "test_version",
|
||||
@@ -421,7 +414,6 @@ async fn test_pagination_cursor() {
|
||||
let head_2 = vec![serde_json::json!({
|
||||
"id": u2,
|
||||
"timestamp": "2025-03-02T09-00-00",
|
||||
"instructions": null,
|
||||
"cwd": ".",
|
||||
"originator": "test_originator",
|
||||
"cli_version": "test_version",
|
||||
@@ -473,7 +465,6 @@ async fn test_pagination_cursor() {
|
||||
let head_1 = vec![serde_json::json!({
|
||||
"id": u1,
|
||||
"timestamp": "2025-03-01T09-00-00",
|
||||
"instructions": null,
|
||||
"cwd": ".",
|
||||
"originator": "test_originator",
|
||||
"cli_version": "test_version",
|
||||
@@ -531,7 +522,6 @@ async fn test_get_thread_contents() {
|
||||
let expected_head = vec![serde_json::json!({
|
||||
"id": uuid,
|
||||
"timestamp": ts,
|
||||
"instructions": null,
|
||||
"cwd": ".",
|
||||
"originator": "test_originator",
|
||||
"cli_version": "test_version",
|
||||
@@ -558,7 +548,6 @@ async fn test_get_thread_contents() {
|
||||
"payload": {
|
||||
"id": uuid,
|
||||
"timestamp": ts,
|
||||
"instructions": null,
|
||||
"cwd": ".",
|
||||
"originator": "test_originator",
|
||||
"cli_version": "test_version",
|
||||
@@ -643,7 +632,6 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> {
|
||||
id: conversation_id,
|
||||
forked_from_id: None,
|
||||
timestamp: ts.to_string(),
|
||||
instructions: None,
|
||||
cwd: ".".into(),
|
||||
originator: "test_originator".into(),
|
||||
cli_version: "test_version".into(),
|
||||
@@ -751,7 +739,6 @@ async fn test_stable_ordering_same_second_pagination() {
|
||||
vec![serde_json::json!({
|
||||
"id": u,
|
||||
"timestamp": ts,
|
||||
"instructions": null,
|
||||
"cwd": ".",
|
||||
"originator": "test_originator",
|
||||
"cli_version": "test_version",
|
||||
|
||||
@@ -189,7 +189,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn ignores_session_prefix_messages_when_truncating_rollout_from_start() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
let mut items = session.build_initial_context(&turn_context);
|
||||
let mut items = session.build_initial_context(&turn_context).await;
|
||||
items.push(user_msg("feature request"));
|
||||
items.push(assistant_msg("ack"));
|
||||
items.push(user_msg("second question"));
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::instructions::SkillInstructions;
|
||||
use crate::skills::SkillLoadOutcome;
|
||||
use crate::skills::SkillMetadata;
|
||||
use crate::user_instructions::SkillInstructions;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use tokio::fs;
|
||||
|
||||
@@ -19,6 +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::openai_models::ModelPreset;
|
||||
use codex_protocol::protocol::InitialHistory;
|
||||
use codex_protocol::protocol::McpServerRefreshConfig;
|
||||
@@ -157,6 +158,10 @@ impl ThreadManager {
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn list_collaboration_modes(&self) -> Vec<CollaborationMode> {
|
||||
self.state.models_manager.list_collaboration_modes()
|
||||
}
|
||||
|
||||
pub async fn list_thread_ids(&self) -> Vec<ThreadId> {
|
||||
self.state.threads.read().await.keys().copied().collect()
|
||||
}
|
||||
@@ -462,7 +467,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn ignores_session_prefix_messages_when_truncating() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
let mut items = session.build_initial_context(&turn_context);
|
||||
let mut items = session.build_initial_context(&turn_context).await;
|
||||
items.push(user_msg("feature request"));
|
||||
items.push(assistant_msg("ack"));
|
||||
items.push(user_msg("second question"));
|
||||
|
||||
@@ -354,6 +354,8 @@ mod tests {
|
||||
async fn unified_exec_timeouts() -> anyhow::Result<()> {
|
||||
skip_if_sandbox!(Ok(()));
|
||||
|
||||
const TEST_VAR_VALUE: &str = "unified_exec_var_123";
|
||||
|
||||
let (session, turn) = test_session_and_turn().await;
|
||||
|
||||
let open_shell = exec_command(&session, &turn, "bash -i", 2_500).await?;
|
||||
@@ -366,7 +368,7 @@ mod tests {
|
||||
write_stdin(
|
||||
&session,
|
||||
process_id,
|
||||
"export CODEX_INTERACTIVE_SHELL_VAR=codex\n",
|
||||
format!("export CODEX_INTERACTIVE_SHELL_VAR={TEST_VAR_VALUE}\n").as_str(),
|
||||
2_500,
|
||||
)
|
||||
.await?;
|
||||
@@ -379,7 +381,7 @@ mod tests {
|
||||
)
|
||||
.await?;
|
||||
assert!(
|
||||
!out_2.output.contains("codex"),
|
||||
!out_2.output.contains(TEST_VAR_VALUE),
|
||||
"timeout too short should yield incomplete output"
|
||||
);
|
||||
|
||||
@@ -388,7 +390,7 @@ mod tests {
|
||||
let out_3 = write_stdin(&session, process_id, "", 100).await?;
|
||||
|
||||
assert!(
|
||||
out_3.output.contains("codex"),
|
||||
out_3.output.contains(TEST_VAR_VALUE),
|
||||
"subsequent poll should retrieve output"
|
||||
);
|
||||
|
||||
|
||||
45
codex-rs/core/templates/collaboration_mode/execute.md
Normal file
45
codex-rs/core/templates/collaboration_mode/execute.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Collaboration Style: Execute
|
||||
You execute on a well-specified task independently and report progress.
|
||||
|
||||
You do not collaborate on decisions in this mode. You execute end-to-end.
|
||||
You make reasonable assumptions when the user hasn't specified something, and you proceed without asking questions.
|
||||
|
||||
## Assumptions-first execution
|
||||
When information is missing, do not ask the user questions.
|
||||
Instead:
|
||||
- Make a sensible assumption.
|
||||
- Clearly state the assumption in the final message (briefly).
|
||||
- Continue executing.
|
||||
|
||||
Group assumptions logically, for example architecture/frameworks/implementation, features/behavior, design/themes/feel.
|
||||
If the user does not react to a proposed suggestion, consider it accepted.
|
||||
|
||||
## Execution principles
|
||||
*Think out loud.* Share reasoning when it helps the user evaluate tradeoffs. Keep explanations short and grounded in consequences. Avoid design lectures or exhaustive option lists.
|
||||
|
||||
*Use reasonable assumptions.* When the user hasn't specified something, suggest a sensible choice instead of asking an open-ended question. Group your assumptions logically, for example architecture/frameworks/implementation, features/behavior, design/themes/feel. Clearly label suggestions as provisional. Share reasoning when it helps the user evaluate tradeoffs. Keep explanations short and grounded in consequences. They should be easy to accept or override. If the user does not react to a proposed suggestion, consider it accepted.
|
||||
|
||||
Example: "There are a few viable ways to structure this. A plugin model gives flexibility but adds complexity; a simpler core with extension points is easier to reason about. Given what you've said about your team's size, I'd lean towards the latter."
|
||||
Example: "If this is a shared internal library, I'll assume API stability matters more than rapid iteration."
|
||||
|
||||
*Think ahead.* What else might the user need? How will the user test and understand what you did? Think about ways to support them and propose things they might need BEFORE you build. Offer at least one suggestion you came up with by thinking ahead.
|
||||
Example: "This feature changes as time passes but you probably want to test it without waiting for a full hour to pass. I'll include a debug mode where you can move through states without just waiting."
|
||||
|
||||
*Be mindful of time.* The user is right here with you. Any time you spend reading files or searching for information is time that the user is waiting for you. Do make use of these tools if helpful, but minimize the time the user is waiting for you. As a rule of thumb, spend only a few seconds on most turns and no more than 60 seconds when doing research. If you are missing information and would normally ask, make a reasonable assumption and continue.
|
||||
Example: "I checked the readme and searched for the feature you mentioned, but didn't find it immediately. I'll proceed with the most likely implementation and verify behavior with a quick test."
|
||||
|
||||
## Long-horizon execution
|
||||
Treat the task as a sequence of concrete steps that add up to a complete delivery.
|
||||
- Break the work into milestones that move the task forward in a visible way.
|
||||
- Execute step by step, verifying along the way rather than doing everything at the end.
|
||||
- If the task is large, keep a running checklist of what is done, what is next, and what is blocked.
|
||||
- Avoid blocking on uncertainty: choose a reasonable default and continue.
|
||||
|
||||
## Reporting progress
|
||||
In this phase you show progress on your task and appraise the user of your progress using plan tool.
|
||||
- Provide updates that directly map to the work you are doing (what changed, what you verified, what remains).
|
||||
- If something fails, report what failed, what you tried, and what you will do next.
|
||||
- When you finish, summarize what you delivered and how the user can validate it.
|
||||
|
||||
## Executing
|
||||
Once you start working, you should execute independently. Your job is to deliver the task and report progress.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Collaboration Style: Pair Programming
|
||||
|
||||
## Build together as you go
|
||||
You treat collaboration as pairing by default. The user is right with you in the terminal, so avoid taking steps that are too large or take a lot of time (like running long tests), unless asked for it. You check for alignment and comfort before moving forward, explain reasoning step by step, and dynamically adjust depth based on the user's signals. There is no need to ask multiple rounds of questions—build as you go. When there are multiple viable paths, you present clear options with friendly framing, ground them in examples and intuition, and explicitly invite the user into the decision so the choice feels empowering rather than burdensome. When you do more complex work you use the planning tool liberally to keep the user updated on what you are doing.
|
||||
|
||||
## Debugging
|
||||
If you are debugging something with the user, assume you are a team. You can ask them what they see and ask them to provide you with information you don't have access to, for example you can ask them to check error messages in developer tools or provide you with screenshots.
|
||||
40
codex-rs/core/templates/collaboration_mode/plan.md
Normal file
40
codex-rs/core/templates/collaboration_mode/plan.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Collaboration Style: Plan
|
||||
You work in 2 distinct modes:
|
||||
1. Brainstorming: You collaboratively align with the user on what to do or build and how to do it or build it.
|
||||
2. Writing and confirming a plan: After you've gathered all the information you write up a plan and verify it with the user.
|
||||
You usually start with the planning step. Skip step 1 if the user provides you with a detailed plan or a small, unambiguous task or plan OR if the user asks you to plan by yourself.
|
||||
|
||||
## Brainstorming principles
|
||||
The point of brainstorming with the user is to align on what to do and how to do it. This phase is iterative and conversational. You can interact with the environment and read files if it is helpful, but be mindful of the time.
|
||||
You MUST follow the principles below. Think about them carefully as you work with the user. Follow the structure and tone of the examples.
|
||||
|
||||
*State what you think the user cares about.* Actively infer what matters most (robustness, clean abstractions, quick lovable interfaces, scalability) and reflect this back to the user to confirm.
|
||||
Example: "It seems like you might be prototyping a design for an app, and scalability or performance isn't a concern right now - is that accurate?"
|
||||
|
||||
*Think out loud.* Share reasoning when it helps the user evaluate tradeoffs. Keep explanations short and grounded in consequences. Avoid design lectures or exhaustive option lists.
|
||||
|
||||
*Use reasonable suggestions.* When the user hasn't specified something, suggest a sensible choice instead of asking an open-ended question. Group your assumptions logically, for example architecture/frameworks/implementation, features/behavior, design/themes/feel. Clearly label suggestions as provisional. Share reasoning when it helps the user evaluate tradeoffs. Keep explanations short and grounded in consequences. They should be easy to accept or override. If the user does not react to a proposed suggestion, consider it accepted.
|
||||
|
||||
Example: "There are a few viable ways to structure this. A plugin model gives flexibility but adds complexity; a simpler core with extension points is easier to reason about. Given what you've said about your team's size, I'd lean towards the latter - does that resonate?"
|
||||
Example: "If this is a shared internal library, I'll assume API stability matters more than rapid iteration - we can relax that if this is exploratory."
|
||||
|
||||
*Ask fewer, better questions.* Prefer making a concrete proposal with stated assumptions over asking questions. Only ask questions when different reasonable suggestions would materially change the plan, you cannot safely proceed, or if you think the user would really want to give input directly. Never ask a question if you already provided a suggestion.
|
||||
|
||||
*Think ahead.* What else might the user need? How will the user test and understand what you did? Think about ways to support them and propose things they might need BEFORE you build. Offer at least one suggestion you came up with by thinking ahead.
|
||||
Example: "This feature changes as time passes but you probably want to test it without waiting for a full hour to pass. Would you like a debug mode where you can move through states without just waiting?"
|
||||
|
||||
*Be mindful of time.* The user is right here with you. Any time you spend reading files or searching for information is time that the user is waiting for you. Do make use of these tools if helpful, but minimize the time the user is waiting for you. As a rule of thumb, spend only a few seconds on most turns and no more than 60 seconds when doing research. If you are missing information and think you need to do longer research, ask the user whether they want you to research, or want to give you a tip.
|
||||
Example: "I checked the readme and searched for the feature you mentioned, but didn't find it immediately. If it's ok, I'll go and spend a bit more time exploring the code base?"
|
||||
|
||||
## Iterating on the plan
|
||||
Only AFTER you have all the information, write up the full plan.
|
||||
A well written and informative plan should be as detailed as a design doc or PRD and reflect your discussion with the user, at minimum that's one full page! If handed to a different agent, the agent would know exactly what to build without asking questions and arrive at a similar implementation to yours. At minimum it should include:
|
||||
- tools and frameworks you use, any dependencies you need to install
|
||||
- functions, files, or directories you're likely going to edit
|
||||
- architecture if the code changes are significant
|
||||
- if developing features, describe the features you are going to build in detail like a PM in a PRD
|
||||
- if you are developing a frontend, describe the design in detail
|
||||
|
||||
`plan.md`: For long, detailed plans, it makes sense to write them in a separate file. If the changes are substantial and the plan is longer than a full page, ask the user if it's ok to write the plan in `plan.md`. If plan.md is used, ALWAYS update the file rather than outputting the plan in your final answer.
|
||||
|
||||
ALWAYS confirm the plan with the user before ending. If the user requests changes or additions to the plan update the plan. Iterate until the user confirms the plan.
|
||||
500
codex-rs/core/tests/suite/collaboration_instructions.rs
Normal file
500
codex-rs/core/tests/suite/collaboration_instructions.rs
Normal file
@@ -0,0 +1,500 @@
|
||||
use anyhow::Result;
|
||||
use codex_core::protocol::COLLABORATION_MODE_CLOSE_TAG;
|
||||
use codex_core::protocol::COLLABORATION_MODE_OPEN_TAG;
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::config_types::Settings;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_response_created;
|
||||
use core_test_support::responses::mount_sse_once;
|
||||
use core_test_support::responses::sse;
|
||||
use core_test_support::responses::start_mock_server;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
|
||||
fn sse_completed(id: &str) -> String {
|
||||
sse(vec![ev_response_created(id), ev_completed(id)])
|
||||
}
|
||||
|
||||
fn collab_mode_with_instructions(instructions: Option<&str>) -> CollaborationMode {
|
||||
CollaborationMode::Custom(Settings {
|
||||
model: "gpt-5.1".to_string(),
|
||||
reasoning_effort: None,
|
||||
developer_instructions: instructions.map(str::to_string),
|
||||
})
|
||||
}
|
||||
|
||||
fn developer_texts(input: &[Value]) -> Vec<String> {
|
||||
input
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
let role = item.get("role")?.as_str()?;
|
||||
if role != "developer" {
|
||||
return None;
|
||||
}
|
||||
let text = item
|
||||
.get("content")?
|
||||
.as_array()?
|
||||
.first()?
|
||||
.get("text")?
|
||||
.as_str()?;
|
||||
Some(text.to_string())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn collab_xml(text: &str) -> String {
|
||||
format!("{COLLABORATION_MODE_OPEN_TAG}{text}{COLLABORATION_MODE_CLOSE_TAG}")
|
||||
}
|
||||
|
||||
fn count_exact(texts: &[String], target: &str) -> usize {
|
||||
texts.iter().filter(|text| text.as_str() == target).count()
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn no_collaboration_instructions_by_default() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let req = mount_sse_once(&server, sse_completed("resp-1")).await;
|
||||
|
||||
let test = test_codex().build(&server).await?;
|
||||
|
||||
test.codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let input = req.single_request().input();
|
||||
let dev_texts = developer_texts(&input);
|
||||
assert_eq!(dev_texts.len(), 1);
|
||||
assert!(dev_texts[0].contains("`approval_policy`"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn user_input_includes_collaboration_instructions_after_override() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let req = mount_sse_once(&server, sse_completed("resp-1")).await;
|
||||
|
||||
let test = test_codex().build(&server).await?;
|
||||
|
||||
let collab_text = "collab instructions";
|
||||
let collaboration_mode = collab_mode_with_instructions(Some(collab_text));
|
||||
test.codex
|
||||
.submit(Op::OverrideTurnContext {
|
||||
cwd: None,
|
||||
approval_policy: None,
|
||||
sandbox_policy: None,
|
||||
model: None,
|
||||
effort: None,
|
||||
summary: None,
|
||||
collaboration_mode: Some(collaboration_mode),
|
||||
})
|
||||
.await?;
|
||||
|
||||
test.codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let input = req.single_request().input();
|
||||
let dev_texts = developer_texts(&input);
|
||||
let collab_text = collab_xml(collab_text);
|
||||
assert_eq!(count_exact(&dev_texts, &collab_text), 1);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn collaboration_instructions_added_on_user_turn() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let req = mount_sse_once(&server, sse_completed("resp-1")).await;
|
||||
|
||||
let test = test_codex().build(&server).await?;
|
||||
let collab_text = "turn instructions";
|
||||
let collaboration_mode = collab_mode_with_instructions(Some(collab_text));
|
||||
|
||||
test.codex
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
cwd: test.config.cwd.clone(),
|
||||
approval_policy: test.config.approval_policy.value(),
|
||||
sandbox_policy: test.config.sandbox_policy.get().clone(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: None,
|
||||
summary: test.config.model_reasoning_summary,
|
||||
collaboration_mode: Some(collaboration_mode),
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let input = req.single_request().input();
|
||||
let dev_texts = developer_texts(&input);
|
||||
let collab_text = collab_xml(collab_text);
|
||||
assert_eq!(count_exact(&dev_texts, &collab_text), 1);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn override_then_user_turn_uses_updated_collaboration_instructions() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let req = mount_sse_once(&server, sse_completed("resp-1")).await;
|
||||
|
||||
let test = test_codex().build(&server).await?;
|
||||
let collab_text = "override instructions";
|
||||
let collaboration_mode = collab_mode_with_instructions(Some(collab_text));
|
||||
|
||||
test.codex
|
||||
.submit(Op::OverrideTurnContext {
|
||||
cwd: None,
|
||||
approval_policy: None,
|
||||
sandbox_policy: None,
|
||||
model: None,
|
||||
effort: None,
|
||||
summary: None,
|
||||
collaboration_mode: Some(collaboration_mode),
|
||||
})
|
||||
.await?;
|
||||
|
||||
test.codex
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
cwd: test.config.cwd.clone(),
|
||||
approval_policy: test.config.approval_policy.value(),
|
||||
sandbox_policy: test.config.sandbox_policy.get().clone(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: None,
|
||||
summary: test.config.model_reasoning_summary,
|
||||
collaboration_mode: None,
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let input = req.single_request().input();
|
||||
let dev_texts = developer_texts(&input);
|
||||
let collab_text = collab_xml(collab_text);
|
||||
assert_eq!(count_exact(&dev_texts, &collab_text), 1);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn user_turn_overrides_collaboration_instructions_after_override() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let req = mount_sse_once(&server, sse_completed("resp-1")).await;
|
||||
|
||||
let test = test_codex().build(&server).await?;
|
||||
let base_text = "base instructions";
|
||||
let base_mode = collab_mode_with_instructions(Some(base_text));
|
||||
let turn_text = "turn override";
|
||||
let turn_mode = collab_mode_with_instructions(Some(turn_text));
|
||||
|
||||
test.codex
|
||||
.submit(Op::OverrideTurnContext {
|
||||
cwd: None,
|
||||
approval_policy: None,
|
||||
sandbox_policy: None,
|
||||
model: None,
|
||||
effort: None,
|
||||
summary: None,
|
||||
collaboration_mode: Some(base_mode),
|
||||
})
|
||||
.await?;
|
||||
|
||||
test.codex
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
cwd: test.config.cwd.clone(),
|
||||
approval_policy: test.config.approval_policy.value(),
|
||||
sandbox_policy: test.config.sandbox_policy.get().clone(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: None,
|
||||
summary: test.config.model_reasoning_summary,
|
||||
collaboration_mode: Some(turn_mode),
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let input = req.single_request().input();
|
||||
let dev_texts = developer_texts(&input);
|
||||
let base_text = collab_xml(base_text);
|
||||
let turn_text = collab_xml(turn_text);
|
||||
assert_eq!(count_exact(&dev_texts, &base_text), 1);
|
||||
assert_eq!(count_exact(&dev_texts, &turn_text), 1);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn collaboration_mode_update_emits_new_instruction_message() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let _req1 = mount_sse_once(&server, sse_completed("resp-1")).await;
|
||||
let req2 = mount_sse_once(&server, sse_completed("resp-2")).await;
|
||||
|
||||
let test = test_codex().build(&server).await?;
|
||||
let first_text = "first instructions";
|
||||
let second_text = "second instructions";
|
||||
|
||||
test.codex
|
||||
.submit(Op::OverrideTurnContext {
|
||||
cwd: None,
|
||||
approval_policy: None,
|
||||
sandbox_policy: None,
|
||||
model: None,
|
||||
effort: None,
|
||||
summary: None,
|
||||
collaboration_mode: Some(collab_mode_with_instructions(Some(first_text))),
|
||||
})
|
||||
.await?;
|
||||
|
||||
test.codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello 1".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
test.codex
|
||||
.submit(Op::OverrideTurnContext {
|
||||
cwd: None,
|
||||
approval_policy: None,
|
||||
sandbox_policy: None,
|
||||
model: None,
|
||||
effort: None,
|
||||
summary: None,
|
||||
collaboration_mode: Some(collab_mode_with_instructions(Some(second_text))),
|
||||
})
|
||||
.await?;
|
||||
|
||||
test.codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello 2".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let input = req2.single_request().input();
|
||||
let dev_texts = developer_texts(&input);
|
||||
let first_text = collab_xml(first_text);
|
||||
let second_text = collab_xml(second_text);
|
||||
assert_eq!(count_exact(&dev_texts, &first_text), 1);
|
||||
assert_eq!(count_exact(&dev_texts, &second_text), 1);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn collaboration_mode_update_noop_does_not_append() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let _req1 = mount_sse_once(&server, sse_completed("resp-1")).await;
|
||||
let req2 = mount_sse_once(&server, sse_completed("resp-2")).await;
|
||||
|
||||
let test = test_codex().build(&server).await?;
|
||||
let collab_text = "same instructions";
|
||||
|
||||
test.codex
|
||||
.submit(Op::OverrideTurnContext {
|
||||
cwd: None,
|
||||
approval_policy: None,
|
||||
sandbox_policy: None,
|
||||
model: None,
|
||||
effort: None,
|
||||
summary: None,
|
||||
collaboration_mode: Some(collab_mode_with_instructions(Some(collab_text))),
|
||||
})
|
||||
.await?;
|
||||
|
||||
test.codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello 1".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
test.codex
|
||||
.submit(Op::OverrideTurnContext {
|
||||
cwd: None,
|
||||
approval_policy: None,
|
||||
sandbox_policy: None,
|
||||
model: None,
|
||||
effort: None,
|
||||
summary: None,
|
||||
collaboration_mode: Some(collab_mode_with_instructions(Some(collab_text))),
|
||||
})
|
||||
.await?;
|
||||
|
||||
test.codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello 2".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let input = req2.single_request().input();
|
||||
let dev_texts = developer_texts(&input);
|
||||
let collab_text = collab_xml(collab_text);
|
||||
assert_eq!(count_exact(&dev_texts, &collab_text), 1);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn resume_replays_collaboration_instructions() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let _req1 = mount_sse_once(&server, sse_completed("resp-1")).await;
|
||||
let req2 = mount_sse_once(&server, sse_completed("resp-2")).await;
|
||||
|
||||
let mut builder = test_codex();
|
||||
let initial = builder.build(&server).await?;
|
||||
let rollout_path = initial.session_configured.rollout_path.clone();
|
||||
let home = initial.home.clone();
|
||||
|
||||
let collab_text = "resume instructions";
|
||||
initial
|
||||
.codex
|
||||
.submit(Op::OverrideTurnContext {
|
||||
cwd: None,
|
||||
approval_policy: None,
|
||||
sandbox_policy: None,
|
||||
model: None,
|
||||
effort: None,
|
||||
summary: None,
|
||||
collaboration_mode: Some(collab_mode_with_instructions(Some(collab_text))),
|
||||
})
|
||||
.await?;
|
||||
|
||||
initial
|
||||
.codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let resumed = builder.resume(&server, home, rollout_path).await?;
|
||||
resumed
|
||||
.codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "after resume".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let input = req2.single_request().input();
|
||||
let dev_texts = developer_texts(&input);
|
||||
let collab_text = collab_xml(collab_text);
|
||||
assert_eq!(count_exact(&dev_texts, &collab_text), 1);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn empty_collaboration_instructions_are_ignored() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let req = mount_sse_once(&server, sse_completed("resp-1")).await;
|
||||
|
||||
let test = test_codex().build(&server).await?;
|
||||
|
||||
test.codex
|
||||
.submit(Op::OverrideTurnContext {
|
||||
cwd: None,
|
||||
approval_policy: None,
|
||||
sandbox_policy: None,
|
||||
model: None,
|
||||
effort: None,
|
||||
summary: None,
|
||||
collaboration_mode: Some(collab_mode_with_instructions(Some(""))),
|
||||
})
|
||||
.await?;
|
||||
|
||||
test.codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let input = req.single_request().input();
|
||||
let dev_texts = developer_texts(&input);
|
||||
assert_eq!(dev_texts.len(), 1);
|
||||
let collab_text = collab_xml("");
|
||||
assert_eq!(count_exact(&dev_texts, &collab_text), 0);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -24,6 +24,7 @@ mod cli_stream;
|
||||
mod client;
|
||||
mod client_websockets;
|
||||
mod codex_delegate;
|
||||
mod collaboration_instructions;
|
||||
mod compact;
|
||||
mod compact_remote;
|
||||
mod compact_resume_fork;
|
||||
|
||||
216
codex-rs/core/tests/suite/override_updates.rs
Normal file
216
codex-rs/core/tests/suite/override_updates.rs
Normal file
@@ -0,0 +1,216 @@
|
||||
use anyhow::Result;
|
||||
use codex_core::config::Constrained;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::COLLABORATION_MODE_CLOSE_TAG;
|
||||
use codex_core::protocol::COLLABORATION_MODE_OPEN_TAG;
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::RolloutItem;
|
||||
use codex_core::protocol::RolloutLine;
|
||||
use codex_core::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG;
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::config_types::Settings;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use core_test_support::responses::start_mock_server;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn collab_mode_with_instructions(instructions: Option<&str>) -> CollaborationMode {
|
||||
CollaborationMode::Custom(Settings {
|
||||
model: "gpt-5.1".to_string(),
|
||||
reasoning_effort: None,
|
||||
developer_instructions: instructions.map(str::to_string),
|
||||
})
|
||||
}
|
||||
|
||||
fn collab_xml(text: &str) -> String {
|
||||
format!("{COLLABORATION_MODE_OPEN_TAG}{text}{COLLABORATION_MODE_CLOSE_TAG}")
|
||||
}
|
||||
|
||||
async fn read_rollout_text(path: &Path) -> anyhow::Result<String> {
|
||||
for _ in 0..50 {
|
||||
if path.exists()
|
||||
&& let Ok(text) = std::fs::read_to_string(path)
|
||||
&& !text.trim().is_empty()
|
||||
{
|
||||
return Ok(text);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
Ok(std::fs::read_to_string(path)?)
|
||||
}
|
||||
|
||||
fn rollout_developer_texts(text: &str) -> Vec<String> {
|
||||
let mut texts = Vec::new();
|
||||
for line in text.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let rollout: RolloutLine = match serde_json::from_str(trimmed) {
|
||||
Ok(rollout) => rollout,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if let RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. }) =
|
||||
rollout.item
|
||||
&& role == "developer"
|
||||
{
|
||||
for item in content {
|
||||
if let ContentItem::InputText { text } = item {
|
||||
texts.push(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
texts
|
||||
}
|
||||
|
||||
fn rollout_environment_texts(text: &str) -> Vec<String> {
|
||||
let mut texts = Vec::new();
|
||||
for line in text.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let rollout: RolloutLine = match serde_json::from_str(trimmed) {
|
||||
Ok(rollout) => rollout,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if let RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. }) =
|
||||
rollout.item
|
||||
&& role == "user"
|
||||
{
|
||||
for item in content {
|
||||
if let ContentItem::InputText { text } = item
|
||||
&& text.starts_with(ENVIRONMENT_CONTEXT_OPEN_TAG)
|
||||
{
|
||||
texts.push(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
texts
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn override_turn_context_records_permissions_update() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let mut builder = test_codex().with_config(|config| {
|
||||
config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
test.codex
|
||||
.submit(Op::OverrideTurnContext {
|
||||
cwd: None,
|
||||
approval_policy: Some(AskForApproval::Never),
|
||||
sandbox_policy: None,
|
||||
model: None,
|
||||
effort: None,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
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_text = read_rollout_text(&rollout_path).await?;
|
||||
let developer_texts = rollout_developer_texts(&rollout_text);
|
||||
let approval_texts: Vec<&String> = developer_texts
|
||||
.iter()
|
||||
.filter(|text| text.contains("`approval_policy`"))
|
||||
.collect();
|
||||
assert!(
|
||||
approval_texts
|
||||
.iter()
|
||||
.any(|text| text.contains("`approval_policy` is `never`")),
|
||||
"expected updated approval policy instructions in rollout"
|
||||
);
|
||||
let unique: HashSet<&String> = approval_texts.iter().copied().collect();
|
||||
assert_eq!(unique.len(), 2);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn override_turn_context_records_environment_update() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let test = test_codex().build(&server).await?;
|
||||
let new_cwd = TempDir::new()?;
|
||||
|
||||
test.codex
|
||||
.submit(Op::OverrideTurnContext {
|
||||
cwd: Some(new_cwd.path().to_path_buf()),
|
||||
approval_policy: None,
|
||||
sandbox_policy: None,
|
||||
model: None,
|
||||
effort: None,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
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_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();
|
||||
assert!(
|
||||
env_texts.iter().any(|text| text.contains(&new_cwd_text)),
|
||||
"expected environment update with new cwd in rollout"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn override_turn_context_records_collaboration_update() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let test = test_codex().build(&server).await?;
|
||||
let collab_text = "override collaboration instructions";
|
||||
let collaboration_mode = collab_mode_with_instructions(Some(collab_text));
|
||||
|
||||
test.codex
|
||||
.submit(Op::OverrideTurnContext {
|
||||
cwd: None,
|
||||
approval_policy: None,
|
||||
sandbox_policy: None,
|
||||
model: None,
|
||||
effort: None,
|
||||
summary: None,
|
||||
collaboration_mode: Some(collaboration_mode),
|
||||
})
|
||||
.await?;
|
||||
|
||||
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_text = read_rollout_text(&rollout_path).await?;
|
||||
let developer_texts = rollout_developer_texts(&rollout_text);
|
||||
let collab_text = collab_xml(collab_text);
|
||||
let collab_count = developer_texts
|
||||
.iter()
|
||||
.filter(|text| text.as_str() == collab_text.as_str())
|
||||
.count();
|
||||
assert_eq!(collab_count, 1);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -132,7 +132,7 @@ async fn permissions_message_added_on_override_change() -> Result<()> {
|
||||
let permissions_2 = permissions_texts(input2);
|
||||
|
||||
assert_eq!(permissions_1.len(), 1);
|
||||
assert_eq!(permissions_2.len(), 2);
|
||||
assert_eq!(permissions_2.len(), 3);
|
||||
let unique = permissions_2.into_iter().collect::<HashSet<String>>();
|
||||
assert_eq!(unique.len(), 2);
|
||||
|
||||
@@ -257,7 +257,7 @@ async fn resume_replays_permissions_messages() -> Result<()> {
|
||||
let body3 = req3.single_request().body_json();
|
||||
let input = body3["input"].as_array().expect("input array");
|
||||
let permissions = permissions_texts(input);
|
||||
assert_eq!(permissions.len(), 3);
|
||||
assert_eq!(permissions.len(), 4);
|
||||
let unique = permissions.into_iter().collect::<HashSet<String>>();
|
||||
assert_eq!(unique.len(), 2);
|
||||
|
||||
@@ -321,7 +321,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> {
|
||||
let body2 = req2.single_request().body_json();
|
||||
let input2 = body2["input"].as_array().expect("input array");
|
||||
let permissions_base = permissions_texts(input2);
|
||||
assert_eq!(permissions_base.len(), 2);
|
||||
assert_eq!(permissions_base.len(), 3);
|
||||
|
||||
builder = builder.with_config(|config| {
|
||||
config.approval_policy = Constrained::allow_any(AskForApproval::UnlessTrusted);
|
||||
|
||||
@@ -379,15 +379,18 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an
|
||||
"content": [ { "type": "input_text", "text": "hello 2" } ]
|
||||
});
|
||||
let expected_permissions_msg = body1["input"][0].clone();
|
||||
// After overriding the turn context, emit a new permissions message.
|
||||
let body1_input = body1["input"].as_array().expect("input array");
|
||||
// After overriding the turn context, emit two updated permissions messages.
|
||||
let expected_permissions_msg_2 = body2["input"][body1_input.len()].clone();
|
||||
let expected_permissions_msg_3 = body2["input"][body1_input.len() + 1].clone();
|
||||
assert_ne!(
|
||||
expected_permissions_msg_2, expected_permissions_msg,
|
||||
"expected updated permissions message after override"
|
||||
);
|
||||
let mut expected_body2 = body1["input"].as_array().expect("input array").to_vec();
|
||||
assert_eq!(expected_permissions_msg_2, expected_permissions_msg_3);
|
||||
let mut expected_body2 = body1_input.to_vec();
|
||||
expected_body2.push(expected_permissions_msg_2);
|
||||
expected_body2.push(expected_permissions_msg_3);
|
||||
expected_body2.push(expected_user_message_2);
|
||||
assert_eq!(body2["input"], serde_json::Value::Array(expected_body2));
|
||||
|
||||
|
||||
@@ -515,7 +515,6 @@ async fn review_input_isolated_from_parent_history() {
|
||||
"payload": {
|
||||
"id": convo_id,
|
||||
"timestamp": "2024-01-01T00:00:00Z",
|
||||
"instructions": null,
|
||||
"cwd": ".",
|
||||
"originator": "test_originator",
|
||||
"cli_version": "test_version",
|
||||
|
||||
@@ -25,7 +25,6 @@ fn write_minimal_rollout_with_id(codex_home: &Path, id: Uuid) -> PathBuf {
|
||||
"payload": {
|
||||
"id": id,
|
||||
"timestamp": "2024-01-01T00:00:00Z",
|
||||
"instructions": null,
|
||||
"cwd": ".",
|
||||
"originator": "test",
|
||||
"cli_version": "test",
|
||||
|
||||
@@ -20,6 +20,7 @@ At a glance:
|
||||
- Configuration and info
|
||||
- `getUserSavedConfig`, `setDefaultModel`, `getUserAgent`, `userInfo`
|
||||
- `model/list` → enumerate available models and reasoning options
|
||||
- `collaborationMode/list` → enumerate collaboration mode presets (experimental)
|
||||
- Auth
|
||||
- `account/read`, `account/login/start`, `account/login/cancel`, `account/logout`, `account/rateLimits/read`
|
||||
- notifications: `account/login/completed`, `account/updated`, `account/rateLimits/updated`
|
||||
@@ -96,6 +97,12 @@ Each response yields:
|
||||
- `isDefault` – whether the model is recommended for most users
|
||||
- `nextCursor` – pass into the next request to continue paging (optional)
|
||||
|
||||
## Collaboration modes (experimental)
|
||||
|
||||
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
|
||||
|
||||
## Event stream
|
||||
|
||||
While a conversation runs, the server sends notifications:
|
||||
|
||||
@@ -135,7 +135,7 @@ pub enum AltScreenMode {
|
||||
#[serde(tag = "mode", rename_all = "lowercase")]
|
||||
pub enum CollaborationMode {
|
||||
Plan(Settings),
|
||||
Collaborate(Settings),
|
||||
PairProgramming(Settings),
|
||||
Execute(Settings),
|
||||
Custom(Settings),
|
||||
}
|
||||
@@ -145,7 +145,7 @@ impl CollaborationMode {
|
||||
fn settings(&self) -> &Settings {
|
||||
match self {
|
||||
CollaborationMode::Plan(settings)
|
||||
| CollaborationMode::Collaborate(settings)
|
||||
| CollaborationMode::PairProgramming(settings)
|
||||
| CollaborationMode::Execute(settings)
|
||||
| CollaborationMode::Custom(settings) => settings,
|
||||
}
|
||||
@@ -182,7 +182,9 @@ impl CollaborationMode {
|
||||
|
||||
match self {
|
||||
CollaborationMode::Plan(_) => CollaborationMode::Plan(updated_settings),
|
||||
CollaborationMode::Collaborate(_) => CollaborationMode::Collaborate(updated_settings),
|
||||
CollaborationMode::PairProgramming(_) => {
|
||||
CollaborationMode::PairProgramming(updated_settings)
|
||||
}
|
||||
CollaborationMode::Execute(_) => CollaborationMode::Execute(updated_settings),
|
||||
CollaborationMode::Custom(_) => CollaborationMode::Custom(updated_settings),
|
||||
}
|
||||
|
||||
@@ -10,8 +10,11 @@ use serde::Serialize;
|
||||
use serde::ser::Serializer;
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::config_types::CollaborationMode;
|
||||
use crate::config_types::SandboxMode;
|
||||
use crate::protocol::AskForApproval;
|
||||
use crate::protocol::COLLABORATION_MODE_CLOSE_TAG;
|
||||
use crate::protocol::COLLABORATION_MODE_OPEN_TAG;
|
||||
use crate::protocol::NetworkAccess;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::protocol::WritableRoot;
|
||||
@@ -230,6 +233,25 @@ impl DeveloperInstructions {
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns developer instructions from a collaboration mode if they exist and are non-empty.
|
||||
pub fn from_collaboration_mode(collaboration_mode: &CollaborationMode) -> Option<Self> {
|
||||
let settings = match collaboration_mode {
|
||||
CollaborationMode::Plan(settings)
|
||||
| CollaborationMode::PairProgramming(settings)
|
||||
| CollaborationMode::Execute(settings)
|
||||
| CollaborationMode::Custom(settings) => settings,
|
||||
};
|
||||
settings
|
||||
.developer_instructions
|
||||
.as_ref()
|
||||
.filter(|instructions| !instructions.is_empty())
|
||||
.map(|instructions| {
|
||||
DeveloperInstructions::new(format!(
|
||||
"{COLLABORATION_MODE_OPEN_TAG}{instructions}{COLLABORATION_MODE_CLOSE_TAG}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn from_permissions_with_network(
|
||||
sandbox_mode: SandboxMode,
|
||||
network_access: NetworkAccess,
|
||||
|
||||
45
codex-rs/protocol/src/prompts/collaboration_mode/execute.md
Normal file
45
codex-rs/protocol/src/prompts/collaboration_mode/execute.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Collaboration Style: Execute
|
||||
You execute on a well-specified task independently and report progress.
|
||||
|
||||
You do not collaborate on decisions in this mode. You execute end-to-end.
|
||||
You make reasonable assumptions when the user hasn't specified something, and you proceed without asking questions.
|
||||
|
||||
## Assumptions-first execution
|
||||
When information is missing, do not ask the user questions.
|
||||
Instead:
|
||||
- Make a sensible assumption.
|
||||
- Clearly state the assumption in the final message (briefly).
|
||||
- Continue executing.
|
||||
|
||||
Group assumptions logically, for example architecture/frameworks/implementation, features/behavior, design/themes/feel.
|
||||
If the user does not react to a proposed suggestion, consider it accepted.
|
||||
|
||||
## Execution principles
|
||||
*Think out loud.* Share reasoning when it helps the user evaluate tradeoffs. Keep explanations short and grounded in consequences. Avoid design lectures or exhaustive option lists.
|
||||
|
||||
*Use reasonable assumptions.* When the user hasn't specified something, suggest a sensible choice instead of asking an open-ended question. Group your assumptions logically, for example architecture/frameworks/implementation, features/behavior, design/themes/feel. Clearly label suggestions as provisional. Share reasoning when it helps the user evaluate tradeoffs. Keep explanations short and grounded in consequences. They should be easy to accept or override. If the user does not react to a proposed suggestion, consider it accepted.
|
||||
|
||||
Example: "There are a few viable ways to structure this. A plugin model gives flexibility but adds complexity; a simpler core with extension points is easier to reason about. Given what you've said about your team's size, I'd lean towards the latter."
|
||||
Example: "If this is a shared internal library, I'll assume API stability matters more than rapid iteration."
|
||||
|
||||
*Think ahead.* What else might the user need? How will the user test and understand what you did? Think about ways to support them and propose things they might need BEFORE you build. Offer at least one suggestion you came up with by thinking ahead.
|
||||
Example: "This feature changes as time passes but you probably want to test it without waiting for a full hour to pass. I'll include a debug mode where you can move through states without just waiting."
|
||||
|
||||
*Be mindful of time.* The user is right here with you. Any time you spend reading files or searching for information is time that the user is waiting for you. Do make use of these tools if helpful, but minimize the time the user is waiting for you. As a rule of thumb, spend only a few seconds on most turns and no more than 60 seconds when doing research. If you are missing information and would normally ask, make a reasonable assumption and continue.
|
||||
Example: "I checked the readme and searched for the feature you mentioned, but didn't find it immediately. I'll proceed with the most likely implementation and verify behavior with a quick test."
|
||||
|
||||
## Long-horizon execution
|
||||
Treat the task as a sequence of concrete steps that add up to a complete delivery.
|
||||
- Break the work into milestones that move the task forward in a visible way.
|
||||
- Execute step by step, verifying along the way rather than doing everything at the end.
|
||||
- If the task is large, keep a running checklist of what is done, what is next, and what is blocked.
|
||||
- Avoid blocking on uncertainty: choose a reasonable default and continue.
|
||||
|
||||
## Reporting progress
|
||||
In this phase you show progress on your task and appraise the user of your progress using plan tool.
|
||||
- Provide updates that directly map to the work you are doing (what changed, what you verified, what remains).
|
||||
- If something fails, report what failed, what you tried, and what you will do next.
|
||||
- When you finish, summarize what you delivered and how the user can validate it.
|
||||
|
||||
## Executing
|
||||
Once you start working, you should execute independently. Your job is to deliver the task and report progress.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Collaboration Style: Pair Programming
|
||||
|
||||
## Build together as you go
|
||||
You treat collaboration as pairing by default. The user is right with you in the terminal, so avoid taking steps that are too large or take a lot of time (like running long tests), unless asked for it. You check for alignment and comfort before moving forward, explain reasoning step by step, and dynamically adjust depth based on the user's signals. There is no need to ask multiple rounds of questions—build as you go. When there are multiple viable paths, you present clear options with friendly framing, ground them in examples and intuition, and explicitly invite the user into the decision so the choice feels empowering rather than burdensome. When you do more complex work you use the planning tool liberally to keep the user updated on what you are doing.
|
||||
|
||||
## Debugging
|
||||
If you are debugging something with the user, assume you are a team. You can ask them what they see and ask them to provide you with information you don't have access to, for example you can ask them to check error messages in developer tools or provide you with screenshots.
|
||||
40
codex-rs/protocol/src/prompts/collaboration_mode/plan.md
Normal file
40
codex-rs/protocol/src/prompts/collaboration_mode/plan.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Collaboration Style: Plan
|
||||
You work in 2 distinct modes:
|
||||
1. Brainstorming: You collaboratively align with the user on what to do or build and how to do it or build it.
|
||||
2. Writing and confirming a plan: After you've gathered all the information you write up a plan and verify it with the user.
|
||||
You usually start with the planning step. Skip step 1 if the user provides you with a detailed plan or a small, unambiguous task or plan OR if the user asks you to plan by yourself.
|
||||
|
||||
## Brainstorming principles
|
||||
The point of brainstorming with the user is to align on what to do and how to do it. This phase is iterative and conversational. You can interact with the environment and read files if it is helpful, but be mindful of the time.
|
||||
You MUST follow the principles below. Think about them carefully as you work with the user. Follow the structure and tone of the examples.
|
||||
|
||||
*State what you think the user cares about.* Actively infer what matters most (robustness, clean abstractions, quick lovable interfaces, scalability) and reflect this back to the user to confirm.
|
||||
Example: "It seems like you might be prototyping a design for an app, and scalability or performance isn't a concern right now - is that accurate?"
|
||||
|
||||
*Think out loud.* Share reasoning when it helps the user evaluate tradeoffs. Keep explanations short and grounded in consequences. Avoid design lectures or exhaustive option lists.
|
||||
|
||||
*Use reasonable suggestions.* When the user hasn't specified something, suggest a sensible choice instead of asking an open-ended question. Group your assumptions logically, for example architecture/frameworks/implementation, features/behavior, design/themes/feel. Clearly label suggestions as provisional. Share reasoning when it helps the user evaluate tradeoffs. Keep explanations short and grounded in consequences. They should be easy to accept or override. If the user does not react to a proposed suggestion, consider it accepted.
|
||||
|
||||
Example: "There are a few viable ways to structure this. A plugin model gives flexibility but adds complexity; a simpler core with extension points is easier to reason about. Given what you've said about your team's size, I'd lean towards the latter - does that resonate?"
|
||||
Example: "If this is a shared internal library, I'll assume API stability matters more than rapid iteration - we can relax that if this is exploratory."
|
||||
|
||||
*Ask fewer, better questions.* Prefer making a concrete proposal with stated assumptions over asking questions. Only ask questions when different reasonable suggestions would materially change the plan, you cannot safely proceed, or if you think the user would really want to give input directly. Never ask a question if you already provided a suggestion.
|
||||
|
||||
*Think ahead.* What else might the user need? How will the user test and understand what you did? Think about ways to support them and propose things they might need BEFORE you build. Offer at least one suggestion you came up with by thinking ahead.
|
||||
Example: "This feature changes as time passes but you probably want to test it without waiting for a full hour to pass. Would you like a debug mode where you can move through states without just waiting?"
|
||||
|
||||
*Be mindful of time.* The user is right here with you. Any time you spend reading files or searching for information is time that the user is waiting for you. Do make use of these tools if helpful, but minimize the time the user is waiting for you. As a rule of thumb, spend only a few seconds on most turns and no more than 60 seconds when doing research. If you are missing information and think you need to do longer research, ask the user whether they want you to research, or want to give you a tip.
|
||||
Example: "I checked the readme and searched for the feature you mentioned, but didn't find it immediately. If it's ok, I'll go and spend a bit more time exploring the code base?"
|
||||
|
||||
## Iterating on the plan
|
||||
Only AFTER you have all the information, write up the full plan.
|
||||
A well written and informative plan should be as detailed as a design doc or PRD and reflect your discussion with the user, at minimum that's one full page! If handed to a different agent, the agent would know exactly what to build without asking questions and arrive at a similar implementation to yours. At minimum it should include:
|
||||
- tools and frameworks you use, any dependencies you need to install
|
||||
- functions, files, or directories you're likely going to edit
|
||||
- architecture if the code changes are significant
|
||||
- if developing features, describe the features you are going to build in detail like a PM in a PRD
|
||||
- if you are developing a frontend, describe the design in detail
|
||||
|
||||
`plan.md`: For long, detailed plans, it makes sense to write them in a separate file. If the changes are substantial and the plan is longer than a full page, ask the user if it's ok to write the plan in `plan.md`. If plan.md is used, ALWAYS update the file rather than outputting the plan in your final answer.
|
||||
|
||||
ALWAYS confirm the plan with the user before ending. If the user requests changes or additions to the plan update the plan. Iterate until the user confirms the plan.
|
||||
@@ -51,6 +51,8 @@ pub const USER_INSTRUCTIONS_OPEN_TAG: &str = "<user_instructions>";
|
||||
pub const USER_INSTRUCTIONS_CLOSE_TAG: &str = "</user_instructions>";
|
||||
pub const ENVIRONMENT_CONTEXT_OPEN_TAG: &str = "<environment_context>";
|
||||
pub const ENVIRONMENT_CONTEXT_CLOSE_TAG: &str = "</environment_context>";
|
||||
pub const COLLABORATION_MODE_OPEN_TAG: &str = "<collaboration_mode>";
|
||||
pub const COLLABORATION_MODE_CLOSE_TAG: &str = "</collaboration_mode>";
|
||||
pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:";
|
||||
|
||||
/// Submission Queue Entry - requests from user
|
||||
@@ -1492,7 +1494,6 @@ pub struct SessionMeta {
|
||||
pub cwd: PathBuf,
|
||||
pub originator: String,
|
||||
pub cli_version: String,
|
||||
pub instructions: Option<String>,
|
||||
#[serde(default)]
|
||||
pub source: SessionSource,
|
||||
pub model_provider: Option<String>,
|
||||
@@ -1507,7 +1508,6 @@ impl Default for SessionMeta {
|
||||
cwd: PathBuf::new(),
|
||||
originator: String::new(),
|
||||
cli_version: String::new(),
|
||||
instructions: None,
|
||||
source: SessionSource::default(),
|
||||
model_provider: None,
|
||||
}
|
||||
|
||||
@@ -340,6 +340,11 @@ pub(crate) struct App {
|
||||
|
||||
// Esc-backtracking state grouped
|
||||
pub(crate) backtrack: crate::app_backtrack::BacktrackState,
|
||||
/// When set, the next draw re-renders the transcript into terminal scrollback once.
|
||||
///
|
||||
/// This is used after a confirmed thread rollback to ensure scrollback reflects the trimmed
|
||||
/// transcript cells.
|
||||
pub(crate) backtrack_render_pending: bool,
|
||||
pub(crate) feedback: codex_feedback::CodexFeedback,
|
||||
/// Set when the user confirms an update; propagated on exit.
|
||||
pub(crate) pending_update_action: Option<UpdateAction>,
|
||||
@@ -362,6 +367,8 @@ pub(crate) struct App {
|
||||
impl App {
|
||||
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.
|
||||
self.backtrack.pending_rollback = None;
|
||||
self.suppress_shutdown_complete = true;
|
||||
self.chat_widget.submit_op(Op::Shutdown);
|
||||
self.server.remove_thread(&thread_id).await;
|
||||
@@ -502,6 +509,7 @@ impl App {
|
||||
has_emitted_history_lines: false,
|
||||
commit_anim_running: Arc::new(AtomicBool::new(false)),
|
||||
backtrack: BacktrackState::default(),
|
||||
backtrack_render_pending: false,
|
||||
feedback: feedback.clone(),
|
||||
pending_update_action: None,
|
||||
suppress_shutdown_complete: false,
|
||||
@@ -622,6 +630,10 @@ impl App {
|
||||
self.chat_widget.handle_paste(pasted);
|
||||
}
|
||||
TuiEvent::Draw => {
|
||||
if self.backtrack_render_pending {
|
||||
self.backtrack_render_pending = false;
|
||||
self.render_transcript_once(tui);
|
||||
}
|
||||
self.chat_widget.maybe_post_pending_notification(tui);
|
||||
if self
|
||||
.chat_widget
|
||||
@@ -870,6 +882,9 @@ impl App {
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
self.handle_codex_event_now(event);
|
||||
if self.backtrack_render_pending {
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
}
|
||||
AppEvent::ExternalApprovalRequest { thread_id, event } => {
|
||||
self.handle_external_approval_request(thread_id, event);
|
||||
@@ -1416,6 +1431,7 @@ impl App {
|
||||
let errors = errors_for_cwd(&cwd, response);
|
||||
emit_skill_load_warnings(&self.app_event_tx, &errors);
|
||||
}
|
||||
self.handle_backtrack_event(&event.msg);
|
||||
self.chat_widget.handle_codex_event(event);
|
||||
}
|
||||
|
||||
@@ -1740,6 +1756,7 @@ mod tests {
|
||||
enhanced_keys_supported: false,
|
||||
commit_anim_running: Arc::new(AtomicBool::new(false)),
|
||||
backtrack: BacktrackState::default(),
|
||||
backtrack_render_pending: false,
|
||||
feedback: codex_feedback::CodexFeedback::new(),
|
||||
pending_update_action: None,
|
||||
suppress_shutdown_complete: false,
|
||||
@@ -1782,6 +1799,7 @@ mod tests {
|
||||
enhanced_keys_supported: false,
|
||||
commit_anim_running: Arc::new(AtomicBool::new(false)),
|
||||
backtrack: BacktrackState::default(),
|
||||
backtrack_render_pending: false,
|
||||
feedback: codex_feedback::CodexFeedback::new(),
|
||||
pending_update_action: None,
|
||||
suppress_shutdown_complete: false,
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
//! This file owns backtrack mode (Esc/Enter navigation in the transcript overlay) and also
|
||||
//! mediates a key rendering boundary for the transcript overlay.
|
||||
//!
|
||||
//! Overall goal: keep the main chat view and the transcript overlay in sync while allowing
|
||||
//! users to "rewind" to an earlier user message. We stage a rollback request, wait for core to
|
||||
//! confirm it, then trim the local transcript to the matching history boundary. This avoids UI
|
||||
//! state diverging from the agent if a rollback fails or targets a different thread.
|
||||
//!
|
||||
//! Backtrack operates as a small state machine:
|
||||
//! - The first `Esc` in the main view "primes" the feature and captures a base thread id.
|
||||
//! - A subsequent `Esc` opens the transcript overlay (`Ctrl+T`) and highlights a user message.
|
||||
//! - `Enter` requests a rollback from core and records a `pending_rollback` guard.
|
||||
//! - Only after receiving `EventMsg::ThreadRolledBack` do we trim local transcript state and
|
||||
//! schedule a one-time scrollback refresh.
|
||||
//!
|
||||
//! The transcript overlay (`Ctrl+T`) renders committed transcript cells plus a render-only live
|
||||
//! tail derived from the current in-flight `ChatWidget.active_cell`.
|
||||
//!
|
||||
@@ -20,6 +32,9 @@ use crate::history_cell::UserHistoryCell;
|
||||
use crate::pager_overlay::Overlay;
|
||||
use crate::tui;
|
||||
use crate::tui::TuiEvent;
|
||||
use codex_core::protocol::CodexErrorInfo;
|
||||
use codex_core::protocol::ErrorEvent;
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_protocol::ThreadId;
|
||||
use color_eyre::eyre::Result;
|
||||
@@ -33,24 +48,54 @@ pub(crate) struct BacktrackState {
|
||||
/// True when Esc has primed backtrack mode in the main view.
|
||||
pub(crate) primed: bool,
|
||||
/// Session id of the base thread to rollback.
|
||||
///
|
||||
/// If the current thread changes, backtrack selections become invalid and must be ignored.
|
||||
pub(crate) base_id: Option<ThreadId>,
|
||||
/// Index in the transcript of the last user message.
|
||||
/// Index of the currently highlighted user message.
|
||||
///
|
||||
/// This is an index into the filtered "user messages since the last session start" view,
|
||||
/// not an index into `transcript_cells`. `usize::MAX` indicates "no selection".
|
||||
pub(crate) nth_user_message: usize,
|
||||
/// True when the transcript overlay is showing a backtrack preview.
|
||||
pub(crate) overlay_preview_active: bool,
|
||||
/// Pending rollback request awaiting confirmation from core.
|
||||
///
|
||||
/// This acts as a guardrail: once we request a rollback, we block additional backtrack
|
||||
/// submissions until core responds with either a success or failure event.
|
||||
pub(crate) pending_rollback: Option<PendingBacktrackRollback>,
|
||||
}
|
||||
|
||||
/// A user-visible backtrack choice that can be confirmed into a rollback request.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct BacktrackSelection {
|
||||
/// The selected user message, counted from the most recent session start.
|
||||
///
|
||||
/// This value is used both to compute the rollback depth and to trim the local transcript
|
||||
/// after core confirms the rollback.
|
||||
pub(crate) nth_user_message: usize,
|
||||
/// Composer prefill derived from the selected user message.
|
||||
///
|
||||
/// This is applied immediately on selection confirmation; if the rollback fails, the prefill
|
||||
/// remains as a convenience so the user can retry or edit.
|
||||
pub(crate) prefill: String,
|
||||
}
|
||||
|
||||
/// An in-flight rollback requested from core.
|
||||
///
|
||||
/// We keep enough information to apply the corresponding local trim only if the response targets
|
||||
/// the same active thread we issued the request for.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct PendingBacktrackRollback {
|
||||
pub(crate) selection: BacktrackSelection,
|
||||
pub(crate) thread_id: Option<ThreadId>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// Route overlay events when transcript overlay is active.
|
||||
/// - If backtrack preview is active: Esc steps selection; Enter confirms.
|
||||
/// - Otherwise: Esc begins preview; all other events forward to overlay.
|
||||
/// interactions (Esc to step target, Enter to confirm) and overlay lifecycle.
|
||||
/// Route overlay events while the transcript overlay is active.
|
||||
///
|
||||
/// If backtrack preview is active, Esc / Left steps selection, Right steps forward, Enter
|
||||
/// confirms. Otherwise, Esc begins preview mode and all other events are forwarded to the
|
||||
/// overlay.
|
||||
pub(crate) async fn handle_backtrack_overlay_event(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
@@ -66,6 +111,22 @@ impl App {
|
||||
self.overlay_step_backtrack(tui, event)?;
|
||||
Ok(true)
|
||||
}
|
||||
TuiEvent::Key(KeyEvent {
|
||||
code: KeyCode::Left,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
}) => {
|
||||
self.overlay_step_backtrack(tui, event)?;
|
||||
Ok(true)
|
||||
}
|
||||
TuiEvent::Key(KeyEvent {
|
||||
code: KeyCode::Right,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
}) => {
|
||||
self.overlay_step_backtrack_forward(tui, event)?;
|
||||
Ok(true)
|
||||
}
|
||||
TuiEvent::Key(KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
kind: KeyEventKind::Press,
|
||||
@@ -112,22 +173,38 @@ impl App {
|
||||
}
|
||||
|
||||
/// Stage a backtrack and request thread history from the agent.
|
||||
///
|
||||
/// We send the rollback request immediately, but we only mutate the transcript after core
|
||||
/// confirms success so the UI cannot get ahead of the actual thread state.
|
||||
///
|
||||
/// The composer prefill is applied immediately as a UX convenience; it does not imply that
|
||||
/// core has accepted the rollback.
|
||||
pub(crate) fn apply_backtrack_rollback(&mut self, selection: BacktrackSelection) {
|
||||
let user_total = user_count(&self.transcript_cells);
|
||||
if user_total == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.backtrack.pending_rollback.is_some() {
|
||||
self.chat_widget
|
||||
.add_error_message("Backtrack rollback already in progress.".to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
let num_turns = user_total.saturating_sub(selection.nth_user_message);
|
||||
let num_turns = u32::try_from(num_turns).unwrap_or(u32::MAX);
|
||||
if num_turns == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let prefill = selection.prefill.clone();
|
||||
self.backtrack.pending_rollback = Some(PendingBacktrackRollback {
|
||||
selection,
|
||||
thread_id: self.chat_widget.thread_id(),
|
||||
});
|
||||
self.chat_widget.submit_op(Op::ThreadRollback { num_turns });
|
||||
self.trim_transcript_for_backtrack(selection.nth_user_message);
|
||||
if !selection.prefill.is_empty() {
|
||||
self.chat_widget.set_composer_text(selection.prefill);
|
||||
if !prefill.is_empty() {
|
||||
self.chat_widget.set_composer_text(prefill);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,6 +294,27 @@ impl App {
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
|
||||
/// Step selection to the next newer user message and update overlay.
|
||||
fn step_forward_backtrack_and_highlight(&mut self, tui: &mut tui::Tui) {
|
||||
let count = user_count(&self.transcript_cells);
|
||||
if count == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let last_index = count.saturating_sub(1);
|
||||
let next_selection = if self.backtrack.nth_user_message == usize::MAX {
|
||||
last_index
|
||||
} else {
|
||||
self.backtrack
|
||||
.nth_user_message
|
||||
.saturating_add(1)
|
||||
.min(last_index)
|
||||
};
|
||||
|
||||
self.apply_backtrack_selection_internal(next_selection);
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
|
||||
/// Apply a computed backtrack selection to the overlay and internal counter.
|
||||
fn apply_backtrack_selection_internal(&mut self, nth_user_message: usize) {
|
||||
if let Some(cell_idx) = nth_user_position(&self.transcript_cells, nth_user_message) {
|
||||
@@ -290,7 +388,6 @@ impl App {
|
||||
self.close_transcript_overlay(tui);
|
||||
if let Some(selection) = selection {
|
||||
self.apply_backtrack_rollback(selection);
|
||||
self.render_transcript_once(tui);
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
}
|
||||
@@ -305,6 +402,20 @@ impl App {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle Right in overlay backtrack preview: step selection forward if armed, else forward.
|
||||
fn overlay_step_backtrack_forward(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
event: TuiEvent,
|
||||
) -> Result<()> {
|
||||
if self.backtrack.base_id.is_some() {
|
||||
self.step_forward_backtrack_and_highlight(tui);
|
||||
} else {
|
||||
self.overlay_forward_event(tui, event)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Confirm a primed backtrack from the main view (no overlay visible).
|
||||
/// Computes the prefill from the selected user message for rollback.
|
||||
pub(crate) fn confirm_backtrack_from_main(&mut self) -> Option<BacktrackSelection> {
|
||||
@@ -328,10 +439,39 @@ impl App {
|
||||
selection: BacktrackSelection,
|
||||
) {
|
||||
self.apply_backtrack_rollback(selection);
|
||||
self.render_transcript_once(tui);
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
|
||||
pub(crate) fn handle_backtrack_event(&mut self, event: &EventMsg) {
|
||||
match event {
|
||||
EventMsg::ThreadRolledBack(_) => self.finish_pending_backtrack(),
|
||||
EventMsg::Error(ErrorEvent {
|
||||
codex_error_info: Some(CodexErrorInfo::ThreadRollbackFailed),
|
||||
..
|
||||
}) => {
|
||||
// Core rejected the rollback; clear the guard so the user can retry.
|
||||
self.backtrack.pending_rollback = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Finish a pending rollback by applying the local trim and scheduling a scrollback refresh.
|
||||
///
|
||||
/// We ignore events that do not correspond to the currently active thread to avoid applying
|
||||
/// stale updates after a session switch.
|
||||
fn finish_pending_backtrack(&mut self) {
|
||||
let Some(pending) = self.backtrack.pending_rollback.take() else {
|
||||
return;
|
||||
};
|
||||
if pending.thread_id != self.chat_widget.thread_id() {
|
||||
// Ignore rollbacks targeting a prior thread.
|
||||
return;
|
||||
}
|
||||
self.trim_transcript_for_backtrack(pending.selection.nth_user_message);
|
||||
self.backtrack_render_pending = true;
|
||||
}
|
||||
|
||||
fn backtrack_selection(&self, nth_user_message: usize) -> Option<BacktrackSelection> {
|
||||
let base_id = self.backtrack.base_id?;
|
||||
if self.chat_widget.thread_id() != Some(base_id) {
|
||||
@@ -350,7 +490,7 @@ impl App {
|
||||
})
|
||||
}
|
||||
|
||||
/// Trim transcript_cells to preserve only content up to the selected user message.
|
||||
/// Trim `transcript_cells` to preserve only content before the selected user message.
|
||||
fn trim_transcript_for_backtrack(&mut self, nth_user_message: usize) {
|
||||
trim_transcript_cells_to_nth_user(&mut self.transcript_cells, nth_user_message);
|
||||
}
|
||||
|
||||
@@ -113,8 +113,8 @@ impl CommandPopup {
|
||||
}
|
||||
|
||||
/// Compute fuzzy-filtered matches over built-in commands and user prompts,
|
||||
/// paired with optional highlight indices and score. Sorted by ascending
|
||||
/// score, then by name for stability.
|
||||
/// paired with optional highlight indices and score. Preserves the original
|
||||
/// presentation order for built-ins and prompts.
|
||||
fn filtered(&self) -> Vec<(CommandItem, Option<Vec<usize>>, i32)> {
|
||||
let filter = self.command_filter.trim();
|
||||
let mut out: Vec<(CommandItem, Option<Vec<usize>>, i32)> = Vec::new();
|
||||
@@ -144,20 +144,6 @@ impl CommandPopup {
|
||||
out.push((CommandItem::UserPrompt(idx), Some(indices), score));
|
||||
}
|
||||
}
|
||||
// When filtering, sort by ascending score and then by name for stability.
|
||||
out.sort_by(|a, b| {
|
||||
a.2.cmp(&b.2).then_with(|| {
|
||||
let an = match a.0 {
|
||||
CommandItem::Builtin(c) => c.command(),
|
||||
CommandItem::UserPrompt(i) => &self.prompts[i].name,
|
||||
};
|
||||
let bn = match b.0 {
|
||||
CommandItem::Builtin(c) => c.command(),
|
||||
CommandItem::UserPrompt(i) => &self.prompts[i].name,
|
||||
};
|
||||
an.cmp(bn)
|
||||
})
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
@@ -292,6 +278,32 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filtered_commands_keep_presentation_order() {
|
||||
let mut popup = CommandPopup::new(Vec::new(), false);
|
||||
popup.on_composer_text_change("/m".to_string());
|
||||
|
||||
let cmds: Vec<&str> = popup
|
||||
.filtered_items()
|
||||
.into_iter()
|
||||
.filter_map(|item| match item {
|
||||
CommandItem::Builtin(cmd) => Some(cmd.command()),
|
||||
CommandItem::UserPrompt(_) => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
cmds,
|
||||
vec![
|
||||
"model",
|
||||
"experimental",
|
||||
"resume",
|
||||
"compact",
|
||||
"mention",
|
||||
"mcp"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_discovery_lists_custom_prompts() {
|
||||
let prompts = vec![
|
||||
|
||||
@@ -92,6 +92,8 @@ const KEY_SPACE: KeyBinding = key_hint::plain(KeyCode::Char(' '));
|
||||
const KEY_SHIFT_SPACE: KeyBinding = key_hint::shift(KeyCode::Char(' '));
|
||||
const KEY_HOME: KeyBinding = key_hint::plain(KeyCode::Home);
|
||||
const KEY_END: KeyBinding = key_hint::plain(KeyCode::End);
|
||||
const KEY_LEFT: KeyBinding = key_hint::plain(KeyCode::Left);
|
||||
const KEY_RIGHT: KeyBinding = key_hint::plain(KeyCode::Right);
|
||||
const KEY_CTRL_F: KeyBinding = key_hint::ctrl(KeyCode::Char('f'));
|
||||
const KEY_CTRL_D: KeyBinding = key_hint::ctrl(KeyCode::Char('d'));
|
||||
const KEY_CTRL_B: KeyBinding = key_hint::ctrl(KeyCode::Char('b'));
|
||||
@@ -637,10 +639,13 @@ impl TranscriptOverlay {
|
||||
let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1);
|
||||
render_key_hints(line1, buf, PAGER_KEY_HINTS);
|
||||
|
||||
let mut pairs: Vec<(&[KeyBinding], &str)> =
|
||||
vec![(&[KEY_Q], "to quit"), (&[KEY_ESC], "to edit prev")];
|
||||
let mut pairs: Vec<(&[KeyBinding], &str)> = vec![(&[KEY_Q], "to quit")];
|
||||
if self.highlight_cell.is_some() {
|
||||
pairs.push((&[KEY_ESC, KEY_LEFT], "to edit prev"));
|
||||
pairs.push((&[KEY_RIGHT], "to edit next"));
|
||||
pairs.push((&[KEY_ENTER], "to edit message"));
|
||||
} else {
|
||||
pairs.push((&[KEY_ESC], "to edit prev"));
|
||||
}
|
||||
render_key_hints(line2, buf, &pairs);
|
||||
}
|
||||
@@ -816,25 +821,37 @@ mod tests {
|
||||
lines: vec![Line::from("hello")],
|
||||
})]);
|
||||
|
||||
// Render into a small buffer and assert the backtrack hint is present
|
||||
let area = Rect::new(0, 0, 40, 10);
|
||||
// Render into a wide buffer so the footer hints aren't truncated.
|
||||
let area = Rect::new(0, 0, 120, 10);
|
||||
let mut buf = Buffer::empty(area);
|
||||
overlay.render(area, &mut buf);
|
||||
|
||||
// Flatten buffer to a string and check for the hint text
|
||||
let mut s = String::new();
|
||||
for y in area.y..area.bottom() {
|
||||
for x in area.x..area.right() {
|
||||
s.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
|
||||
}
|
||||
s.push('\n');
|
||||
}
|
||||
let s = buffer_to_text(&buf, area);
|
||||
assert!(
|
||||
s.contains("edit prev"),
|
||||
"expected 'edit prev' hint in overlay footer, got: {s:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_next_hint_is_visible_when_highlighted() {
|
||||
let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell {
|
||||
lines: vec![Line::from("hello")],
|
||||
})]);
|
||||
overlay.set_highlight_cell(Some(0));
|
||||
|
||||
// Render into a wide buffer so the footer hints aren't truncated.
|
||||
let area = Rect::new(0, 0, 120, 10);
|
||||
let mut buf = Buffer::empty(area);
|
||||
overlay.render(area, &mut buf);
|
||||
|
||||
let s = buffer_to_text(&buf, area);
|
||||
assert!(
|
||||
s.contains("edit next"),
|
||||
"expected 'edit next' hint in overlay footer, got: {s:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_overlay_snapshot_basic() {
|
||||
// Prepare a transcript overlay with a few lines
|
||||
|
||||
@@ -1362,7 +1362,6 @@ mod tests {
|
||||
"cwd": cwd,
|
||||
"originator": "user",
|
||||
"cli_version": "0.0.0",
|
||||
"instructions": null,
|
||||
"source": "Cli",
|
||||
"model_provider": "openai",
|
||||
}
|
||||
|
||||
@@ -406,6 +406,11 @@ pub(crate) struct App {
|
||||
|
||||
// Esc-backtracking state grouped
|
||||
pub(crate) backtrack: crate::app_backtrack::BacktrackState,
|
||||
/// When set, the next draw re-renders the transcript into terminal scrollback once.
|
||||
///
|
||||
/// This is used after a confirmed conversation rollback to ensure scrollback reflects the
|
||||
/// trimmed transcript cells.
|
||||
pub(crate) backtrack_render_pending: bool,
|
||||
pub(crate) feedback: codex_feedback::CodexFeedback,
|
||||
/// Set when the user confirms an update; propagated on exit.
|
||||
pub(crate) pending_update_action: Option<UpdateAction>,
|
||||
@@ -420,6 +425,8 @@ pub(crate) struct App {
|
||||
impl App {
|
||||
async fn shutdown_current_conversation(&mut self) {
|
||||
if let Some(conversation_id) = self.chat_widget.conversation_id() {
|
||||
// Clear any in-flight rollback guard when switching conversations.
|
||||
self.backtrack.pending_rollback = None;
|
||||
self.suppress_shutdown_complete = true;
|
||||
self.chat_widget.submit_op(Op::Shutdown);
|
||||
self.server.remove_thread(&conversation_id).await;
|
||||
@@ -587,6 +594,7 @@ impl App {
|
||||
scroll_config,
|
||||
scroll_state: MouseScrollState::default(),
|
||||
backtrack: BacktrackState::default(),
|
||||
backtrack_render_pending: false,
|
||||
feedback: feedback.clone(),
|
||||
pending_update_action: None,
|
||||
suppress_shutdown_complete: false,
|
||||
@@ -711,6 +719,10 @@ impl App {
|
||||
self.chat_widget.handle_paste(pasted);
|
||||
}
|
||||
TuiEvent::Draw => {
|
||||
if self.backtrack_render_pending {
|
||||
self.backtrack_render_pending = false;
|
||||
self.render_transcript_once(tui);
|
||||
}
|
||||
self.chat_widget.maybe_post_pending_notification(tui);
|
||||
if self
|
||||
.chat_widget
|
||||
@@ -1598,6 +1610,8 @@ impl App {
|
||||
self.transcript_cells.push(cell.clone());
|
||||
if self.overlay.is_some() {
|
||||
self.deferred_history_cells.push(cell);
|
||||
} else {
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
}
|
||||
AppEvent::StartCommitAnimation => {
|
||||
@@ -1634,7 +1648,11 @@ impl App {
|
||||
let errors = errors_for_cwd(&cwd, response);
|
||||
emit_skill_load_warnings(&self.app_event_tx, &errors);
|
||||
}
|
||||
self.handle_backtrack_event(&event.msg);
|
||||
self.chat_widget.handle_codex_event(event);
|
||||
if self.backtrack_render_pending {
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
}
|
||||
AppEvent::Exit(mode) => match mode {
|
||||
ExitMode::ShutdownFirst => self.chat_widget.submit_op(Op::Shutdown),
|
||||
@@ -2363,6 +2381,7 @@ mod tests {
|
||||
scroll_config: ScrollConfig::default(),
|
||||
scroll_state: MouseScrollState::default(),
|
||||
backtrack: BacktrackState::default(),
|
||||
backtrack_render_pending: false,
|
||||
feedback: codex_feedback::CodexFeedback::new(),
|
||||
pending_update_action: None,
|
||||
suppress_shutdown_complete: false,
|
||||
@@ -2416,6 +2435,7 @@ mod tests {
|
||||
scroll_config: ScrollConfig::default(),
|
||||
scroll_state: MouseScrollState::default(),
|
||||
backtrack: BacktrackState::default(),
|
||||
backtrack_render_pending: false,
|
||||
feedback: codex_feedback::CodexFeedback::new(),
|
||||
pending_update_action: None,
|
||||
suppress_shutdown_complete: false,
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
//! This file owns backtrack mode (Esc/Enter navigation in the transcript overlay) and also
|
||||
//! mediates a key rendering boundary for the transcript overlay.
|
||||
//!
|
||||
//! Overall goal: keep the main chat view and the transcript overlay in sync while allowing
|
||||
//! users to "rewind" to an earlier user message. We stage a rollback request, wait for core to
|
||||
//! confirm it, then trim the local transcript to the matching history boundary. This avoids UI
|
||||
//! state diverging from the agent if a rollback fails or targets a different thread.
|
||||
//!
|
||||
//! Backtrack operates as a small state machine:
|
||||
//! - The first `Esc` in the main view "primes" the feature and captures a base conversation id.
|
||||
//! - A subsequent `Esc` opens the transcript overlay (`Ctrl+T`) and highlights a user message.
|
||||
//! - `Enter` requests a rollback from core and records a `pending_rollback` guard.
|
||||
//! - Only after receiving `EventMsg::ThreadRolledBack` do we trim local transcript state and
|
||||
//! schedule a one-time scrollback refresh.
|
||||
//!
|
||||
//! The transcript overlay (`Ctrl+T`) renders committed transcript cells plus a render-only live
|
||||
//! tail derived from the current in-flight `ChatWidget.active_cell`.
|
||||
//!
|
||||
@@ -20,6 +32,9 @@ use crate::history_cell::UserHistoryCell;
|
||||
use crate::pager_overlay::Overlay;
|
||||
use crate::tui;
|
||||
use crate::tui::TuiEvent;
|
||||
use codex_core::protocol::CodexErrorInfo;
|
||||
use codex_core::protocol::ErrorEvent;
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_protocol::ThreadId;
|
||||
use color_eyre::eyre::Result;
|
||||
@@ -32,25 +47,56 @@ use crossterm::event::KeyEventKind;
|
||||
pub(crate) struct BacktrackState {
|
||||
/// True when Esc has primed backtrack mode in the main view.
|
||||
pub(crate) primed: bool,
|
||||
/// Session id of the base thread to rollback.
|
||||
/// Session id of the base conversation to rollback.
|
||||
///
|
||||
/// If the current conversation changes, backtrack selections become invalid and must be
|
||||
/// ignored.
|
||||
pub(crate) base_id: Option<ThreadId>,
|
||||
/// Index in the transcript of the last user message.
|
||||
/// Index of the currently highlighted user message.
|
||||
///
|
||||
/// This is an index into the filtered "user messages since the last session start" view,
|
||||
/// not an index into `transcript_cells`. `usize::MAX` indicates "no selection".
|
||||
pub(crate) nth_user_message: usize,
|
||||
/// True when the transcript overlay is showing a backtrack preview.
|
||||
pub(crate) overlay_preview_active: bool,
|
||||
/// Pending rollback request awaiting confirmation from core.
|
||||
///
|
||||
/// This acts as a guardrail: once we request a rollback, we block additional backtrack
|
||||
/// submissions until core responds with either a success or failure event.
|
||||
pub(crate) pending_rollback: Option<PendingBacktrackRollback>,
|
||||
}
|
||||
|
||||
/// A user-visible backtrack choice that can be confirmed into a rollback request.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct BacktrackSelection {
|
||||
/// The selected user message, counted from the most recent session start.
|
||||
///
|
||||
/// This value is used both to compute the rollback depth and to trim the local transcript
|
||||
/// after core confirms the rollback.
|
||||
pub(crate) nth_user_message: usize,
|
||||
/// Composer prefill derived from the selected user message.
|
||||
///
|
||||
/// This is applied immediately on selection confirmation; if the rollback fails, the prefill
|
||||
/// remains as a convenience so the user can retry or edit.
|
||||
pub(crate) prefill: String,
|
||||
}
|
||||
|
||||
/// An in-flight rollback requested from core.
|
||||
///
|
||||
/// We keep enough information to apply the corresponding local trim only if the response targets
|
||||
/// the same active conversation we issued the request for.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct PendingBacktrackRollback {
|
||||
pub(crate) selection: BacktrackSelection,
|
||||
pub(crate) thread_id: Option<ThreadId>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// Route overlay events when transcript overlay is active.
|
||||
/// - If backtrack preview is active: Esc steps selection; Enter confirms.
|
||||
/// - Otherwise: Esc begins preview; all other events forward to overlay.
|
||||
/// interactions (Esc to step target, Enter to confirm) and overlay lifecycle.
|
||||
/// Route overlay events while the transcript overlay is active.
|
||||
///
|
||||
/// If backtrack preview is active, Esc / Left steps selection, Right steps forward, Enter
|
||||
/// confirms. Otherwise, Esc begins preview mode and all other events are forwarded to the
|
||||
/// overlay.
|
||||
pub(crate) async fn handle_backtrack_overlay_event(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
@@ -66,6 +112,22 @@ impl App {
|
||||
self.overlay_step_backtrack(tui, event)?;
|
||||
Ok(true)
|
||||
}
|
||||
TuiEvent::Key(KeyEvent {
|
||||
code: KeyCode::Left,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
}) => {
|
||||
self.overlay_step_backtrack(tui, event)?;
|
||||
Ok(true)
|
||||
}
|
||||
TuiEvent::Key(KeyEvent {
|
||||
code: KeyCode::Right,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
}) => {
|
||||
self.overlay_step_backtrack_forward(tui, event)?;
|
||||
Ok(true)
|
||||
}
|
||||
TuiEvent::Key(KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
kind: KeyEventKind::Press,
|
||||
@@ -111,22 +173,39 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Stage a backtrack and request thread history from the agent.
|
||||
///
|
||||
/// We send the rollback request immediately, but we only mutate the transcript after core
|
||||
/// confirms success so the UI cannot get ahead of the actual thread state.
|
||||
///
|
||||
/// The composer prefill is applied immediately as a UX convenience; it does not imply that
|
||||
/// core has accepted the rollback.
|
||||
pub(crate) fn apply_backtrack_rollback(&mut self, selection: BacktrackSelection) {
|
||||
let user_total = user_count(&self.transcript_cells);
|
||||
if user_total == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.backtrack.pending_rollback.is_some() {
|
||||
self.chat_widget
|
||||
.add_error_message("Backtrack rollback already in progress.".to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
let num_turns = user_total.saturating_sub(selection.nth_user_message);
|
||||
let num_turns = u32::try_from(num_turns).unwrap_or(u32::MAX);
|
||||
if num_turns == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let prefill = selection.prefill.clone();
|
||||
self.backtrack.pending_rollback = Some(PendingBacktrackRollback {
|
||||
selection,
|
||||
thread_id: self.chat_widget.conversation_id(),
|
||||
});
|
||||
self.chat_widget.submit_op(Op::ThreadRollback { num_turns });
|
||||
self.trim_transcript_for_backtrack(selection.nth_user_message);
|
||||
if !selection.prefill.is_empty() {
|
||||
self.chat_widget.set_composer_text(selection.prefill);
|
||||
if !prefill.is_empty() {
|
||||
self.chat_widget.set_composer_text(prefill);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,6 +325,27 @@ impl App {
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
|
||||
/// Step selection to the next newer user message and update overlay.
|
||||
fn step_forward_backtrack_and_highlight(&mut self, tui: &mut tui::Tui) {
|
||||
let count = user_count(&self.transcript_cells);
|
||||
if count == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let last_index = count.saturating_sub(1);
|
||||
let next_selection = if self.backtrack.nth_user_message == usize::MAX {
|
||||
last_index
|
||||
} else {
|
||||
self.backtrack
|
||||
.nth_user_message
|
||||
.saturating_add(1)
|
||||
.min(last_index)
|
||||
};
|
||||
|
||||
self.apply_backtrack_selection_internal(next_selection);
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
|
||||
/// Apply a computed backtrack selection to the overlay and internal counter.
|
||||
fn apply_backtrack_selection_internal(&mut self, nth_user_message: usize) {
|
||||
if let Some(cell_idx) = nth_user_position(&self.transcript_cells, nth_user_message) {
|
||||
@@ -311,7 +411,6 @@ impl App {
|
||||
self.close_transcript_overlay(tui);
|
||||
if let Some(selection) = selection {
|
||||
self.apply_backtrack_rollback(selection);
|
||||
self.render_transcript_once(tui);
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
}
|
||||
@@ -326,6 +425,20 @@ impl App {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle Right in overlay backtrack preview: step selection forward if armed, else forward.
|
||||
fn overlay_step_backtrack_forward(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
event: TuiEvent,
|
||||
) -> Result<()> {
|
||||
if self.backtrack.base_id.is_some() {
|
||||
self.step_forward_backtrack_and_highlight(tui);
|
||||
} else {
|
||||
self.overlay_forward_event(tui, event)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Confirm a primed backtrack from the main view (no overlay visible).
|
||||
/// Computes the prefill from the selected user message for rollback.
|
||||
pub(crate) fn confirm_backtrack_from_main(&mut self) -> Option<BacktrackSelection> {
|
||||
@@ -349,9 +462,38 @@ impl App {
|
||||
selection: BacktrackSelection,
|
||||
) {
|
||||
self.apply_backtrack_rollback(selection);
|
||||
self.render_transcript_once(tui);
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
|
||||
pub(crate) fn handle_backtrack_event(&mut self, event: &EventMsg) {
|
||||
match event {
|
||||
EventMsg::ThreadRolledBack(_) => self.finish_pending_backtrack(),
|
||||
EventMsg::Error(ErrorEvent {
|
||||
codex_error_info: Some(CodexErrorInfo::ThreadRollbackFailed),
|
||||
..
|
||||
}) => {
|
||||
// Core rejected the rollback; clear the guard so the user can retry.
|
||||
self.backtrack.pending_rollback = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Finish a pending rollback by applying the local trim and scheduling a scrollback refresh.
|
||||
///
|
||||
/// We ignore events that do not correspond to the currently active conversation to avoid
|
||||
/// applying stale updates after a session switch.
|
||||
fn finish_pending_backtrack(&mut self) {
|
||||
let Some(pending) = self.backtrack.pending_rollback.take() else {
|
||||
return;
|
||||
};
|
||||
if pending.thread_id != self.chat_widget.conversation_id() {
|
||||
// Ignore rollbacks targeting a prior thread.
|
||||
return;
|
||||
}
|
||||
self.trim_transcript_for_backtrack(pending.selection.nth_user_message);
|
||||
self.backtrack_render_pending = true;
|
||||
}
|
||||
fn backtrack_selection(&self, nth_user_message: usize) -> Option<BacktrackSelection> {
|
||||
let base_id = self.backtrack.base_id?;
|
||||
if self.chat_widget.conversation_id() != Some(base_id) {
|
||||
@@ -370,7 +512,7 @@ impl App {
|
||||
})
|
||||
}
|
||||
|
||||
/// Trim transcript_cells to preserve only content up to the selected user message.
|
||||
/// Trim `transcript_cells` to preserve only content before the selected user message.
|
||||
fn trim_transcript_for_backtrack(&mut self, nth_user_message: usize) {
|
||||
trim_transcript_cells_to_nth_user(&mut self.transcript_cells, nth_user_message);
|
||||
}
|
||||
|
||||
@@ -113,8 +113,8 @@ impl CommandPopup {
|
||||
}
|
||||
|
||||
/// Compute fuzzy-filtered matches over built-in commands and user prompts,
|
||||
/// paired with optional highlight indices and score. Sorted by ascending
|
||||
/// score, then by name for stability.
|
||||
/// paired with optional highlight indices and score. Preserves the original
|
||||
/// presentation order for built-ins and prompts.
|
||||
fn filtered(&self) -> Vec<(CommandItem, Option<Vec<usize>>, i32)> {
|
||||
let filter = self.command_filter.trim();
|
||||
let mut out: Vec<(CommandItem, Option<Vec<usize>>, i32)> = Vec::new();
|
||||
@@ -144,20 +144,6 @@ impl CommandPopup {
|
||||
out.push((CommandItem::UserPrompt(idx), Some(indices), score));
|
||||
}
|
||||
}
|
||||
// When filtering, sort by ascending score and then by name for stability.
|
||||
out.sort_by(|a, b| {
|
||||
a.2.cmp(&b.2).then_with(|| {
|
||||
let an = match a.0 {
|
||||
CommandItem::Builtin(c) => c.command(),
|
||||
CommandItem::UserPrompt(i) => &self.prompts[i].name,
|
||||
};
|
||||
let bn = match b.0 {
|
||||
CommandItem::Builtin(c) => c.command(),
|
||||
CommandItem::UserPrompt(i) => &self.prompts[i].name,
|
||||
};
|
||||
an.cmp(bn)
|
||||
})
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
@@ -291,6 +277,22 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filtered_commands_keep_presentation_order() {
|
||||
let mut popup = CommandPopup::new(Vec::new(), false);
|
||||
popup.on_composer_text_change("/m".to_string());
|
||||
|
||||
let cmds: Vec<&str> = popup
|
||||
.filtered_items()
|
||||
.into_iter()
|
||||
.filter_map(|item| match item {
|
||||
CommandItem::Builtin(cmd) => Some(cmd.command()),
|
||||
CommandItem::UserPrompt(_) => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(cmds, vec!["model", "resume", "compact", "mention", "mcp"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_discovery_lists_custom_prompts() {
|
||||
let prompts = vec![
|
||||
|
||||
@@ -93,6 +93,8 @@ const KEY_SPACE: KeyBinding = key_hint::plain(KeyCode::Char(' '));
|
||||
const KEY_SHIFT_SPACE: KeyBinding = key_hint::shift(KeyCode::Char(' '));
|
||||
const KEY_HOME: KeyBinding = key_hint::plain(KeyCode::Home);
|
||||
const KEY_END: KeyBinding = key_hint::plain(KeyCode::End);
|
||||
const KEY_LEFT: KeyBinding = key_hint::plain(KeyCode::Left);
|
||||
const KEY_RIGHT: KeyBinding = key_hint::plain(KeyCode::Right);
|
||||
const KEY_CTRL_F: KeyBinding = key_hint::ctrl(KeyCode::Char('f'));
|
||||
const KEY_CTRL_D: KeyBinding = key_hint::ctrl(KeyCode::Char('d'));
|
||||
const KEY_CTRL_B: KeyBinding = key_hint::ctrl(KeyCode::Char('b'));
|
||||
@@ -656,10 +658,13 @@ impl TranscriptOverlay {
|
||||
let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1);
|
||||
render_key_hints(line1, buf, PAGER_KEY_HINTS);
|
||||
|
||||
let mut pairs: Vec<(&[KeyBinding], &str)> =
|
||||
vec![(&[KEY_Q], "to quit"), (&[KEY_ESC], "to edit prev")];
|
||||
let mut pairs: Vec<(&[KeyBinding], &str)> = vec![(&[KEY_Q], "to quit")];
|
||||
if self.highlight_cell.is_some() {
|
||||
pairs.push((&[KEY_ESC, KEY_LEFT], "to edit prev"));
|
||||
pairs.push((&[KEY_RIGHT], "to edit next"));
|
||||
pairs.push((&[KEY_ENTER], "to edit message"));
|
||||
} else {
|
||||
pairs.push((&[KEY_ESC], "to edit prev"));
|
||||
}
|
||||
render_key_hints(line2, buf, &pairs);
|
||||
}
|
||||
@@ -837,25 +842,37 @@ mod tests {
|
||||
lines: vec![Line::from("hello")],
|
||||
})]);
|
||||
|
||||
// Render into a small buffer and assert the backtrack hint is present
|
||||
let area = Rect::new(0, 0, 40, 10);
|
||||
// Render into a wide buffer so the footer hints aren't truncated.
|
||||
let area = Rect::new(0, 0, 120, 10);
|
||||
let mut buf = Buffer::empty(area);
|
||||
overlay.render(area, &mut buf);
|
||||
|
||||
// Flatten buffer to a string and check for the hint text
|
||||
let mut s = String::new();
|
||||
for y in area.y..area.bottom() {
|
||||
for x in area.x..area.right() {
|
||||
s.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
|
||||
}
|
||||
s.push('\n');
|
||||
}
|
||||
let s = buffer_to_text(&buf, area);
|
||||
assert!(
|
||||
s.contains("edit prev"),
|
||||
"expected 'edit prev' hint in overlay footer, got: {s:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_next_hint_is_visible_when_highlighted() {
|
||||
let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell {
|
||||
lines: vec![Line::from("hello")],
|
||||
})]);
|
||||
overlay.set_highlight_cell(Some(0));
|
||||
|
||||
// Render into a wide buffer so the footer hints aren't truncated.
|
||||
let area = Rect::new(0, 0, 120, 10);
|
||||
let mut buf = Buffer::empty(area);
|
||||
overlay.render(area, &mut buf);
|
||||
|
||||
let s = buffer_to_text(&buf, area);
|
||||
assert!(
|
||||
s.contains("edit next"),
|
||||
"expected 'edit next' hint in overlay footer, got: {s:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_overlay_snapshot_basic() {
|
||||
// Prepare a transcript overlay with a few lines
|
||||
|
||||
@@ -1362,7 +1362,6 @@ mod tests {
|
||||
"cwd": cwd,
|
||||
"originator": "user",
|
||||
"cli_version": "0.0.0",
|
||||
"instructions": null,
|
||||
"source": "Cli",
|
||||
"model_provider": "openai",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user