mirror of
https://github.com/openai/codex.git
synced 2026-04-19 22:11:52 +03:00
Compare commits
5 Commits
rust-v0.31
...
jif/undo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee6677d398 | ||
|
|
927ccb3299 | ||
|
|
10537867ad | ||
|
|
fdf52e87c2 | ||
|
|
731a354f6c |
29
.github/workflows/ci.yml
vendored
29
.github/workflows/ci.yml
vendored
@@ -14,18 +14,33 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.8.1
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
shell: bash
|
||||
run: |
|
||||
echo "store_path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.store_path }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
run: pnpm install
|
||||
|
||||
# Run all tasks using workspace filters
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ members = [
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.31.0"
|
||||
version = "0.0.0"
|
||||
# Track the edition for all workspace crates in one place. Individual
|
||||
# crates can still override this value, but keeping it here means new
|
||||
# crates created with `cargo new -w ...` automatically inherit the 2024
|
||||
|
||||
@@ -267,6 +267,12 @@ struct State {
|
||||
pending_input: Vec<ResponseInputItem>,
|
||||
history: ConversationHistory,
|
||||
token_info: Option<TokenUsageInfo>,
|
||||
last_undo_patch: Option<StoredUndoPatch>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct StoredUndoPatch {
|
||||
patch: String,
|
||||
}
|
||||
|
||||
/// Context for an initialized model agent
|
||||
@@ -660,6 +666,19 @@ impl Session {
|
||||
state.approved_commands.insert(cmd);
|
||||
}
|
||||
|
||||
fn store_last_undo_patch(&self, patch: String) {
|
||||
let mut state = self.state.lock_unchecked();
|
||||
state.last_undo_patch = Some(StoredUndoPatch { patch });
|
||||
}
|
||||
|
||||
fn last_undo_patch(&self) -> Option<StoredUndoPatch> {
|
||||
self.state.lock_unchecked().last_undo_patch.clone()
|
||||
}
|
||||
|
||||
fn clear_last_undo_patch(&self) {
|
||||
self.state.lock_unchecked().last_undo_patch = None;
|
||||
}
|
||||
|
||||
/// Records items to both the rollout and the chat completions/ZDR
|
||||
/// transcript, if enabled.
|
||||
async fn record_conversation_items(&self, items: &[ResponseItem]) {
|
||||
@@ -704,6 +723,7 @@ impl Session {
|
||||
user_explicitly_approved_this_action,
|
||||
changes,
|
||||
}) => {
|
||||
self.clear_last_undo_patch();
|
||||
turn_diff_tracker.on_patch_begin(&changes);
|
||||
|
||||
EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
|
||||
@@ -732,8 +752,7 @@ impl Session {
|
||||
async fn on_exec_command_end(
|
||||
&self,
|
||||
turn_diff_tracker: &mut TurnDiffTracker,
|
||||
sub_id: &str,
|
||||
call_id: &str,
|
||||
context: &ExecCommandContext,
|
||||
output: &ExecToolCallOutput,
|
||||
is_apply_patch: bool,
|
||||
) {
|
||||
@@ -752,14 +771,14 @@ impl Session {
|
||||
|
||||
let msg = if is_apply_patch {
|
||||
EventMsg::PatchApplyEnd(PatchApplyEndEvent {
|
||||
call_id: call_id.to_string(),
|
||||
call_id: context.call_id.clone(),
|
||||
stdout,
|
||||
stderr,
|
||||
success: *exit_code == 0,
|
||||
})
|
||||
} else {
|
||||
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id: call_id.to_string(),
|
||||
call_id: context.call_id.clone(),
|
||||
stdout,
|
||||
stderr,
|
||||
aggregated_output,
|
||||
@@ -770,7 +789,7 @@ impl Session {
|
||||
};
|
||||
|
||||
let event = Event {
|
||||
id: sub_id.to_string(),
|
||||
id: context.sub_id.clone(),
|
||||
msg,
|
||||
};
|
||||
let _ = self.tx_event.send(event).await;
|
||||
@@ -778,14 +797,55 @@ impl Session {
|
||||
// If this is an apply_patch, after we emit the end patch, emit a second event
|
||||
// with the full turn diff if there is one.
|
||||
if is_apply_patch {
|
||||
let unified_diff = turn_diff_tracker.get_unified_diff();
|
||||
if let Ok(Some(unified_diff)) = unified_diff {
|
||||
let msg = EventMsg::TurnDiff(TurnDiffEvent { unified_diff });
|
||||
let event = Event {
|
||||
id: sub_id.into(),
|
||||
msg,
|
||||
};
|
||||
let _ = self.tx_event.send(event).await;
|
||||
match turn_diff_tracker.get_unified_diff() {
|
||||
Ok(Some(unified_diff)) => {
|
||||
let msg = EventMsg::TurnDiff(TurnDiffEvent {
|
||||
unified_diff: unified_diff.clone(),
|
||||
});
|
||||
let event = Event {
|
||||
id: context.sub_id.clone(),
|
||||
msg,
|
||||
};
|
||||
let _ = self.tx_event.send(event).await;
|
||||
if *exit_code == 0 {
|
||||
match turn_diff_tracker.build_undo_patch() {
|
||||
Ok(Some(patch)) => {
|
||||
self.store_last_undo_patch(patch);
|
||||
}
|
||||
Ok(None) => {
|
||||
self.clear_last_undo_patch();
|
||||
}
|
||||
Err(error) => {
|
||||
warn!("failed to prepare undo patch: {error:#}");
|
||||
self.clear_last_undo_patch();
|
||||
self.notify_background_event(
|
||||
&context.sub_id,
|
||||
format!("Undo is unavailable for this turn: {error:#}"),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
if *exit_code == 0 {
|
||||
self.clear_last_undo_patch();
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
warn!("failed to compute unified diff: {error:#}");
|
||||
if *exit_code == 0 {
|
||||
self.clear_last_undo_patch();
|
||||
self
|
||||
.notify_background_event(
|
||||
&context.sub_id,
|
||||
format!(
|
||||
"Undo is unavailable for this turn: failed to compute diff: {error:#}"
|
||||
),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -800,8 +860,6 @@ impl Session {
|
||||
exec_args: ExecInvokeArgs<'a>,
|
||||
) -> crate::error::Result<ExecToolCallOutput> {
|
||||
let is_apply_patch = begin_ctx.apply_patch.is_some();
|
||||
let sub_id = begin_ctx.sub_id.clone();
|
||||
let call_id = begin_ctx.call_id.clone();
|
||||
|
||||
self.on_exec_command_begin(turn_diff_tracker, begin_ctx.clone())
|
||||
.await;
|
||||
@@ -829,14 +887,8 @@ impl Session {
|
||||
&output_stderr
|
||||
}
|
||||
};
|
||||
self.on_exec_command_end(
|
||||
turn_diff_tracker,
|
||||
&sub_id,
|
||||
&call_id,
|
||||
borrowed,
|
||||
is_apply_patch,
|
||||
)
|
||||
.await;
|
||||
self.on_exec_command_end(turn_diff_tracker, &begin_ctx, borrowed, is_apply_patch)
|
||||
.await;
|
||||
|
||||
result
|
||||
}
|
||||
@@ -864,6 +916,37 @@ impl Session {
|
||||
let _ = self.tx_event.send(event).await;
|
||||
}
|
||||
|
||||
async fn undo_last_turn_diff(&self, sub_id: &str) {
|
||||
let Some(stored_patch) = self.last_undo_patch() else {
|
||||
self.notify_background_event(sub_id, "No turn diff available to undo.")
|
||||
.await;
|
||||
return;
|
||||
};
|
||||
|
||||
let mut stdout = Vec::new();
|
||||
let mut stderr = Vec::new();
|
||||
match codex_apply_patch::apply_patch(&stored_patch.patch, &mut stdout, &mut stderr) {
|
||||
Ok(()) => {
|
||||
self.clear_last_undo_patch();
|
||||
if stdout.is_empty() {
|
||||
self.notify_background_event(sub_id, "Reverted last turn diff.")
|
||||
.await;
|
||||
} else if let Ok(output) = String::from_utf8(stdout) {
|
||||
self.notify_background_event(sub_id, output).await;
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
let mut message = format!("failed to undo turn diff: {error:#}");
|
||||
if let Ok(stderr_text) = String::from_utf8(stderr)
|
||||
&& !stderr_text.is_empty()
|
||||
{
|
||||
message = format!("{message}\n{stderr_text}");
|
||||
}
|
||||
self.notify_stream_error(sub_id, message).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the full turn input by concatenating the current conversation
|
||||
/// history with additional items for this turn.
|
||||
pub fn turn_input_with_history(&self, extra: Vec<ResponseItem>) -> Vec<ResponseItem> {
|
||||
@@ -1141,6 +1224,9 @@ async fn submission_loop(
|
||||
sess.set_task(task);
|
||||
}
|
||||
}
|
||||
Op::UndoLastTurnDiff => {
|
||||
sess.undo_last_turn_diff(&sub.id).await;
|
||||
}
|
||||
Op::UserTurn {
|
||||
items,
|
||||
cwd,
|
||||
|
||||
@@ -187,13 +187,7 @@ impl McpConnectionManager {
|
||||
let mut clients: HashMap<String, ManagedClient> = HashMap::with_capacity(join_set.len());
|
||||
|
||||
while let Some(res) = join_set.join_next().await {
|
||||
let (server_name, client_res) = match res {
|
||||
Ok((server_name, client_res)) => (server_name, client_res),
|
||||
Err(e) => {
|
||||
warn!("Task panic when starting MCP server: {e:#}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let (server_name, client_res) = res?; // JoinError propagation
|
||||
|
||||
match client_res {
|
||||
Ok((client, startup_timeout)) => {
|
||||
@@ -211,13 +205,7 @@ impl McpConnectionManager {
|
||||
}
|
||||
}
|
||||
|
||||
let all_tools = match list_all_tools(&clients).await {
|
||||
Ok(tools) => tools,
|
||||
Err(e) => {
|
||||
warn!("Failed to list tools from some MCP servers: {e:#}");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
let all_tools = list_all_tools(&clients).await?;
|
||||
|
||||
let tools = qualify_tools(all_tools);
|
||||
|
||||
@@ -282,19 +270,8 @@ async fn list_all_tools(clients: &HashMap<String, ManagedClient>) -> Result<Vec<
|
||||
let mut aggregated: Vec<ToolInfo> = Vec::with_capacity(join_set.len());
|
||||
|
||||
while let Some(join_res) = join_set.join_next().await {
|
||||
let (server_name, list_result) = if let Ok(result) = join_res {
|
||||
result
|
||||
} else {
|
||||
warn!("Task panic when listing tools for MCP server: {join_res:#?}");
|
||||
continue;
|
||||
};
|
||||
|
||||
let list_result = if let Ok(result) = list_result {
|
||||
result
|
||||
} else {
|
||||
warn!("Failed to list tools for MCP server '{server_name}': {list_result:#?}");
|
||||
continue;
|
||||
};
|
||||
let (server_name, list_result) = join_res?;
|
||||
let list_result = list_result?;
|
||||
|
||||
for tool in list_result.tools {
|
||||
let tool_info = ToolInfo {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
@@ -249,6 +250,64 @@ impl TurnDiffTracker {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_undo_patch(&mut self) -> Result<Option<String>> {
|
||||
let mut delete_paths: BTreeSet<PathBuf> = BTreeSet::new();
|
||||
let mut add_entries: Vec<(PathBuf, String)> = Vec::new();
|
||||
|
||||
let mut baseline_file_names: Vec<String> =
|
||||
self.baseline_file_info.keys().cloned().collect();
|
||||
baseline_file_names.sort();
|
||||
|
||||
for internal in baseline_file_names {
|
||||
let Some(info) = self.baseline_file_info.get(&internal) else {
|
||||
continue;
|
||||
};
|
||||
let current_path = self
|
||||
.get_path_for_internal(&internal)
|
||||
.unwrap_or(info.path.clone());
|
||||
if current_path.exists() {
|
||||
delete_paths.insert(current_path);
|
||||
}
|
||||
|
||||
if info.oid.as_str() != ZERO_OID {
|
||||
let content = String::from_utf8(info.content.clone()).map_err(|_| {
|
||||
anyhow!(
|
||||
"undo is not supported for non-UTF8 baseline file {}",
|
||||
info.path.display()
|
||||
)
|
||||
})?;
|
||||
add_entries.push((info.path.clone(), content));
|
||||
}
|
||||
}
|
||||
|
||||
if delete_paths.is_empty() && add_entries.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
add_entries.sort_by(|(left_path, _), (right_path, _)| left_path.cmp(right_path));
|
||||
|
||||
let mut patch = String::from("*** Begin Patch\n");
|
||||
for path in delete_paths {
|
||||
patch.push_str(&format!("*** Delete File: {}\n", path.display()));
|
||||
}
|
||||
|
||||
for (path, content) in add_entries {
|
||||
patch.push_str(&format!("*** Add File: {}\n", path.display()));
|
||||
if !content.is_empty() {
|
||||
for line in content.split_terminator('\n') {
|
||||
patch.push('+');
|
||||
patch.push_str(line);
|
||||
patch.push('\n');
|
||||
}
|
||||
if !content.ends_with('\n') {
|
||||
patch.push_str("+\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
patch.push_str("*** End Patch\n");
|
||||
Ok(Some(patch))
|
||||
}
|
||||
|
||||
fn get_file_diff(&mut self, internal_file_name: &str) -> String {
|
||||
let mut aggregated = String::new();
|
||||
|
||||
@@ -503,6 +562,146 @@ mod tests {
|
||||
out
|
||||
}
|
||||
|
||||
fn normalize_patch_for_test(input: &str, root: &Path) -> String {
|
||||
let root_str = root.display().to_string().replace('\\', "/");
|
||||
let mut replaced = input.replace('\\', "/");
|
||||
replaced = replaced.replace(&root_str, "<TMP>");
|
||||
|
||||
if let Some(root_name) = root.file_name().and_then(|name| name.to_str()) {
|
||||
let marker = format!("/{root_name}");
|
||||
let mut normalized = String::with_capacity(replaced.len());
|
||||
let mut search_start = 0;
|
||||
|
||||
while let Some(relative_pos) = replaced[search_start..].find(&marker) {
|
||||
let absolute_pos = search_start + relative_pos;
|
||||
|
||||
let path_start = replaced[..absolute_pos]
|
||||
.rfind(['\n', ' '])
|
||||
.map(|idx| idx + 1)
|
||||
.unwrap_or(0);
|
||||
|
||||
let prefix_end = replaced[path_start..absolute_pos]
|
||||
.find('/')
|
||||
.map(|idx| path_start + idx + 1)
|
||||
.unwrap_or(path_start);
|
||||
|
||||
normalized.push_str(&replaced[search_start..prefix_end]);
|
||||
normalized.push_str("<TMP>");
|
||||
|
||||
let after_marker = absolute_pos + marker.len();
|
||||
let mut rest_start = after_marker;
|
||||
if after_marker < replaced.len() && replaced.as_bytes()[after_marker] == b'/' {
|
||||
normalized.push('/');
|
||||
rest_start += 1;
|
||||
}
|
||||
|
||||
search_start = rest_start;
|
||||
}
|
||||
|
||||
normalized.push_str(&replaced[search_start..]);
|
||||
replaced = normalized;
|
||||
}
|
||||
|
||||
if !replaced.ends_with('\n') {
|
||||
replaced.push('\n');
|
||||
}
|
||||
replaced
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_undo_patch_returns_none_without_baseline() {
|
||||
let mut tracker = TurnDiffTracker::new();
|
||||
assert_eq!(tracker.build_undo_patch().unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_undo_patch_restores_updated_file() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("undo.txt");
|
||||
fs::write(&path, "before\n").unwrap();
|
||||
|
||||
let mut tracker = TurnDiffTracker::new();
|
||||
let update_changes = HashMap::from([(
|
||||
path.clone(),
|
||||
FileChange::Update {
|
||||
unified_diff: String::new(),
|
||||
move_path: None,
|
||||
},
|
||||
)]);
|
||||
tracker.on_patch_begin(&update_changes);
|
||||
|
||||
fs::write(&path, "after\n").unwrap();
|
||||
|
||||
let patch = tracker
|
||||
.build_undo_patch()
|
||||
.expect("undo patch")
|
||||
.expect("some undo patch");
|
||||
let normalized = normalize_patch_for_test(&patch, dir.path());
|
||||
let expected = concat!(
|
||||
"*** Begin Patch\n",
|
||||
"*** Delete File: <TMP>/undo.txt\n",
|
||||
"*** Add File: <TMP>/undo.txt\n",
|
||||
"+before\n",
|
||||
"*** End Patch\n",
|
||||
);
|
||||
assert_eq!(normalized, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_undo_patch_restores_deleted_file() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("gone.txt");
|
||||
fs::write(&path, "gone\n").unwrap();
|
||||
|
||||
let mut tracker = TurnDiffTracker::new();
|
||||
let delete_changes = HashMap::from([(
|
||||
path.clone(),
|
||||
FileChange::Delete {
|
||||
content: "gone\n".to_string(),
|
||||
},
|
||||
)]);
|
||||
tracker.on_patch_begin(&delete_changes);
|
||||
|
||||
fs::remove_file(&path).unwrap();
|
||||
|
||||
let patch = tracker
|
||||
.build_undo_patch()
|
||||
.expect("undo patch")
|
||||
.expect("some undo patch");
|
||||
let normalized = normalize_patch_for_test(&patch, dir.path());
|
||||
let expected = concat!(
|
||||
"*** Begin Patch\n",
|
||||
"*** Add File: <TMP>/gone.txt\n",
|
||||
"+gone\n",
|
||||
"*** End Patch\n",
|
||||
);
|
||||
assert_eq!(normalized, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_undo_patch_rejects_non_utf8_content() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("binary.bin");
|
||||
fs::write(&path, [0xff, 0xfe, 0x00]).unwrap();
|
||||
|
||||
let mut tracker = TurnDiffTracker::new();
|
||||
let update_changes = HashMap::from([(
|
||||
path.clone(),
|
||||
FileChange::Update {
|
||||
unified_diff: String::new(),
|
||||
move_path: None,
|
||||
},
|
||||
)]);
|
||||
tracker.on_patch_begin(&update_changes);
|
||||
|
||||
let err = tracker.build_undo_patch().unwrap_err();
|
||||
let message = format!("{err:#}");
|
||||
assert!(
|
||||
message.contains("undo is not supported for non-UTF8 baseline file"),
|
||||
"unexpected error message: {message}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accumulates_add_and_update() {
|
||||
let mut acc = TurnDiffTracker::new();
|
||||
|
||||
@@ -19,7 +19,6 @@ use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config::ConfigToml;
|
||||
use codex_core::config::load_config_as_toml;
|
||||
use codex_core::default_client::get_codex_user_agent;
|
||||
use codex_core::exec::ExecParams;
|
||||
use codex_core::exec_env::create_env;
|
||||
use codex_core::get_platform_sandbox;
|
||||
@@ -49,7 +48,6 @@ use codex_protocol::mcp_protocol::ExecArbitraryCommandResponse;
|
||||
use codex_protocol::mcp_protocol::ExecCommandApprovalParams;
|
||||
use codex_protocol::mcp_protocol::ExecCommandApprovalResponse;
|
||||
use codex_protocol::mcp_protocol::ExecOneOffCommandParams;
|
||||
use codex_protocol::mcp_protocol::GetUserAgentResponse;
|
||||
use codex_protocol::mcp_protocol::GetUserSavedConfigResponse;
|
||||
use codex_protocol::mcp_protocol::GitDiffToRemoteResponse;
|
||||
use codex_protocol::mcp_protocol::InputItem as WireInputItem;
|
||||
@@ -171,9 +169,6 @@ impl CodexMessageProcessor {
|
||||
ClientRequest::GetUserSavedConfig { request_id } => {
|
||||
self.get_user_saved_config(request_id).await;
|
||||
}
|
||||
ClientRequest::GetUserAgent { request_id } => {
|
||||
self.get_user_agent(request_id).await;
|
||||
}
|
||||
ClientRequest::ExecOneOffCommand { request_id, params } => {
|
||||
self.exec_one_off_command(request_id, params).await;
|
||||
}
|
||||
@@ -389,12 +384,6 @@ impl CodexMessageProcessor {
|
||||
self.outgoing.send_response(request_id, response).await;
|
||||
}
|
||||
|
||||
async fn get_user_agent(&self, request_id: RequestId) {
|
||||
let user_agent = get_codex_user_agent(Some(&self.config.responses_originator_header));
|
||||
let response = GetUserAgentResponse { user_agent };
|
||||
self.outgoing.send_response(request_id, response).await;
|
||||
}
|
||||
|
||||
async fn get_user_saved_config(&self, request_id: RequestId) {
|
||||
let toml_value = match load_config_as_toml(&self.config.codex_home) {
|
||||
Ok(val) => val,
|
||||
|
||||
@@ -247,11 +247,6 @@ impl McpProcess {
|
||||
self.send_request("getUserSavedConfig", None).await
|
||||
}
|
||||
|
||||
/// Send a `getUserAgent` JSON-RPC request.
|
||||
pub async fn send_get_user_agent_request(&mut self) -> anyhow::Result<i64> {
|
||||
self.send_request("getUserAgent", None).await
|
||||
}
|
||||
|
||||
/// Send a `listConversations` JSON-RPC request.
|
||||
pub async fn send_list_conversations_request(
|
||||
&mut self,
|
||||
|
||||
@@ -8,4 +8,3 @@ mod interrupt;
|
||||
mod list_resume;
|
||||
mod login;
|
||||
mod send_message;
|
||||
mod user_agent;
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
use codex_core::default_client::DEFAULT_ORIGINATOR;
|
||||
use codex_core::default_client::get_codex_user_agent;
|
||||
use codex_protocol::mcp_protocol::GetUserAgentResponse;
|
||||
use mcp_test_support::McpProcess;
|
||||
use mcp_test_support::to_response;
|
||||
use mcp_types::JSONRPCResponse;
|
||||
use mcp_types::RequestId;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
|
||||
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn get_user_agent_returns_current_codex_user_agent() {
|
||||
let codex_home = TempDir::new().unwrap_or_else(|err| panic!("create tempdir: {err}"));
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path())
|
||||
.await
|
||||
.expect("spawn mcp process");
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize())
|
||||
.await
|
||||
.expect("initialize timeout")
|
||||
.expect("initialize request");
|
||||
|
||||
let request_id = mcp
|
||||
.send_get_user_agent_request()
|
||||
.await
|
||||
.expect("send getUserAgent");
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await
|
||||
.expect("getUserAgent timeout")
|
||||
.expect("getUserAgent response");
|
||||
|
||||
let received: GetUserAgentResponse =
|
||||
to_response(response).expect("deserialize getUserAgent response");
|
||||
let expected = GetUserAgentResponse {
|
||||
user_agent: get_codex_user_agent(Some(DEFAULT_ORIGINATOR)),
|
||||
};
|
||||
|
||||
assert_eq!(received, expected);
|
||||
}
|
||||
@@ -47,7 +47,6 @@ pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> {
|
||||
codex_protocol::mcp_protocol::ExecCommandApprovalParams::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::ExecCommandApprovalResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::GetUserSavedConfigResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::GetUserAgentResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::ServerNotification::export_all_to(out_dir)?;
|
||||
|
||||
generate_index_ts(out_dir)?;
|
||||
|
||||
@@ -137,10 +137,6 @@ pub enum ClientRequest {
|
||||
#[serde(rename = "id")]
|
||||
request_id: RequestId,
|
||||
},
|
||||
GetUserAgent {
|
||||
#[serde(rename = "id")]
|
||||
request_id: RequestId,
|
||||
},
|
||||
/// Execute a command (argv vector) under the server's sandbox.
|
||||
ExecOneOffCommand {
|
||||
#[serde(rename = "id")]
|
||||
@@ -343,12 +339,6 @@ pub struct GetAuthStatusResponse {
|
||||
pub auth_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GetUserAgentResponse {
|
||||
pub user_agent: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GetUserSavedConfigResponse {
|
||||
|
||||
@@ -58,6 +58,9 @@ pub enum Op {
|
||||
items: Vec<InputItem>,
|
||||
},
|
||||
|
||||
/// Undo the most recently applied turn diff using the local git repo.
|
||||
UndoLastTurnDiff,
|
||||
|
||||
/// Similar to [`Op::UserInput`], but contains additional context required
|
||||
/// for a turn of a [`crate::codex_conversation::CodexConversation`].
|
||||
UserTurn {
|
||||
@@ -1008,6 +1011,7 @@ pub enum TurnAbortReason {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
/// Serialize Event to verify that its JSON representation has the expected
|
||||
/// amount of nesting.
|
||||
|
||||
@@ -411,6 +411,8 @@ impl ChatWidget {
|
||||
|
||||
fn on_background_event(&mut self, message: String) {
|
||||
debug!("BackgroundEvent: {message}");
|
||||
self.add_to_history(history_cell::new_background_event(message));
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
fn on_stream_error(&mut self, message: String) {
|
||||
@@ -862,6 +864,9 @@ impl ChatWidget {
|
||||
tx.send(AppEvent::DiffResult(text));
|
||||
});
|
||||
}
|
||||
SlashCommand::Undo => {
|
||||
self.open_undo_confirmation_popup();
|
||||
}
|
||||
SlashCommand::Mention => {
|
||||
self.insert_str("@");
|
||||
}
|
||||
@@ -1253,6 +1258,43 @@ impl ChatWidget {
|
||||
);
|
||||
}
|
||||
|
||||
fn open_undo_confirmation_popup(&mut self) {
|
||||
let confirm_message = "Undoing the last Codex turn diff.".to_string();
|
||||
let undo_actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::new_background_event(confirm_message.clone()),
|
||||
)));
|
||||
tx.send(AppEvent::CodexOp(Op::UndoLastTurnDiff));
|
||||
})];
|
||||
|
||||
let items = vec![
|
||||
SelectionItem {
|
||||
name: "Undo last turn diff".to_string(),
|
||||
description: Some(
|
||||
"Revert files that Codex changed during the most recent turn.".to_string(),
|
||||
),
|
||||
is_current: false,
|
||||
actions: undo_actions,
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Cancel".to_string(),
|
||||
description: Some("Close without undoing any files.".to_string()),
|
||||
is_current: false,
|
||||
actions: Vec::new(),
|
||||
},
|
||||
];
|
||||
|
||||
self.bottom_pane.show_selection_view(
|
||||
"Undo last Codex turn?".to_string(),
|
||||
Some(
|
||||
"Codex will apply a patch to restore files from before the previous turn."
|
||||
.to_string(),
|
||||
),
|
||||
Some("Press Enter to confirm or Esc to cancel".to_string()),
|
||||
items,
|
||||
);
|
||||
}
|
||||
|
||||
/// Set the approval policy in the widget's config copy.
|
||||
pub(crate) fn set_approval_policy(&mut self, policy: AskForApproval) {
|
||||
self.config.approval_policy = policy;
|
||||
|
||||
@@ -13,6 +13,7 @@ use codex_core::protocol::AgentMessageEvent;
|
||||
use codex_core::protocol::AgentReasoningDeltaEvent;
|
||||
use codex_core::protocol::AgentReasoningEvent;
|
||||
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
|
||||
use codex_core::protocol::BackgroundEventEvent;
|
||||
use codex_core::protocol::Event;
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::ExecApprovalRequestEvent;
|
||||
@@ -614,6 +615,58 @@ fn disabled_slash_command_while_task_running_snapshot() {
|
||||
assert_snapshot!(blob);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_command_requires_confirmation() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
chat.dispatch_command(SlashCommand::Undo);
|
||||
|
||||
assert!(rx.try_recv().is_err(), "undo should require confirmation");
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
let mut undo_requested = false;
|
||||
let mut history_lines = Vec::new();
|
||||
while let Ok(event) = rx.try_recv() {
|
||||
match event {
|
||||
AppEvent::InsertHistoryCell(cell) => {
|
||||
history_lines.push(cell.display_lines(80));
|
||||
}
|
||||
AppEvent::CodexOp(Op::UndoLastTurnDiff) => {
|
||||
undo_requested = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(undo_requested, "expected undo op after confirmation");
|
||||
|
||||
let combined = history_lines
|
||||
.iter()
|
||||
.map(|lines| lines_to_single_string(lines))
|
||||
.collect::<String>();
|
||||
assert!(combined.contains("Undoing the last Codex turn diff."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_events_are_rendered_in_history() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "undo".to_string(),
|
||||
msg: EventMsg::BackgroundEvent(BackgroundEventEvent {
|
||||
message: "Reverted last turn diff.".to_string(),
|
||||
}),
|
||||
});
|
||||
|
||||
let history = drain_insert_history(&mut rx);
|
||||
let combined = history
|
||||
.iter()
|
||||
.map(|lines| lines_to_single_string(lines))
|
||||
.collect::<String>();
|
||||
assert!(combined.contains("Reverted last turn diff."));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn binary_size_transcript_snapshot() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
@@ -49,60 +49,34 @@ pub struct PastedImageInfo {
|
||||
/// Capture image from system clipboard, encode to PNG, and return bytes + info.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
pub fn paste_image_as_png() -> Result<(Vec<u8>, PastedImageInfo), PasteImageError> {
|
||||
let _span = tracing::debug_span!("paste_image_as_png").entered();
|
||||
tracing::debug!("attempting clipboard image read");
|
||||
let mut cb = arboard::Clipboard::new()
|
||||
.map_err(|e| PasteImageError::ClipboardUnavailable(e.to_string()))?;
|
||||
// Sometimes images on the clipboard come as files (e.g. when copy/pasting from
|
||||
// Finder), sometimes they come as image data (e.g. when pasting from Chrome).
|
||||
// Accept both, and prefer files if both are present.
|
||||
let files = cb
|
||||
.get()
|
||||
.file_list()
|
||||
.map_err(|e| PasteImageError::ClipboardUnavailable(e.to_string()));
|
||||
let dyn_img = if let Some(img) = files
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.find_map(|f| image::open(f).ok())
|
||||
{
|
||||
tracing::debug!(
|
||||
"clipboard image opened from file: {}x{}",
|
||||
img.width(),
|
||||
img.height()
|
||||
);
|
||||
img
|
||||
} else {
|
||||
let _span = tracing::debug_span!("get_image").entered();
|
||||
let img = cb
|
||||
.get_image()
|
||||
.map_err(|e| PasteImageError::NoImage(e.to_string()))?;
|
||||
let w = img.width as u32;
|
||||
let h = img.height as u32;
|
||||
tracing::debug!("clipboard image opened from image: {}x{}", w, h);
|
||||
|
||||
let Some(rgba_img) = image::RgbaImage::from_raw(w, h, img.bytes.into_owned()) else {
|
||||
return Err(PasteImageError::EncodeFailed("invalid RGBA buffer".into()));
|
||||
};
|
||||
|
||||
image::DynamicImage::ImageRgba8(rgba_img)
|
||||
};
|
||||
let img = cb
|
||||
.get_image()
|
||||
.map_err(|e| PasteImageError::NoImage(e.to_string()))?;
|
||||
let w = img.width as u32;
|
||||
let h = img.height as u32;
|
||||
|
||||
let mut png: Vec<u8> = Vec::new();
|
||||
let Some(rgba_img) = image::RgbaImage::from_raw(w, h, img.bytes.into_owned()) else {
|
||||
return Err(PasteImageError::EncodeFailed("invalid RGBA buffer".into()));
|
||||
};
|
||||
let dyn_img = image::DynamicImage::ImageRgba8(rgba_img);
|
||||
tracing::debug!("clipboard image decoded RGBA {w}x{h}");
|
||||
{
|
||||
let span =
|
||||
tracing::debug_span!("encode_image", byte_length = tracing::field::Empty).entered();
|
||||
let mut cursor = std::io::Cursor::new(&mut png);
|
||||
dyn_img
|
||||
.write_to(&mut cursor, image::ImageFormat::Png)
|
||||
.map_err(|e| PasteImageError::EncodeFailed(e.to_string()))?;
|
||||
span.record("byte_length", png.len());
|
||||
}
|
||||
|
||||
tracing::debug!("clipboard image encoded to PNG ({}) bytes", png.len());
|
||||
Ok((
|
||||
png,
|
||||
PastedImageInfo {
|
||||
width: dyn_img.width(),
|
||||
height: dyn_img.height(),
|
||||
width: w,
|
||||
height: h,
|
||||
encoded_format: EncodedImageFormat::Png,
|
||||
},
|
||||
))
|
||||
|
||||
@@ -1064,6 +1064,11 @@ pub(crate) fn new_stream_error_event(message: String) -> PlainHistoryCell {
|
||||
PlainHistoryCell { lines }
|
||||
}
|
||||
|
||||
pub(crate) fn new_background_event(message: String) -> PlainHistoryCell {
|
||||
let lines: Vec<Line<'static>> = vec![vec![padded_emoji("ℹ️").into(), message.into()].into()];
|
||||
PlainHistoryCell { lines }
|
||||
}
|
||||
|
||||
/// Render a user‑friendly plan update styled like a checkbox todo list.
|
||||
pub(crate) fn new_plan_update(update: UpdatePlanArgs) -> PlanUpdateCell {
|
||||
let UpdatePlanArgs { explanation, plan } = update;
|
||||
@@ -1177,14 +1182,13 @@ pub(crate) fn new_proposed_command(command: &[String]) -> PlainHistoryCell {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
lines.push(Line::from(vec!["• ".into(), "Proposed Command".bold()]));
|
||||
|
||||
let highlighted_lines = crate::render::highlight::highlight_bash_to_lines(&cmd);
|
||||
let cmd_lines: Vec<Line<'static>> = cmd
|
||||
.lines()
|
||||
.map(|part| Line::from(part.to_string()))
|
||||
.collect();
|
||||
let initial_prefix: Span<'static> = " └ ".dim();
|
||||
let subsequent_prefix: Span<'static> = " ".into();
|
||||
lines.extend(prefix_lines(
|
||||
highlighted_lines,
|
||||
initial_prefix,
|
||||
subsequent_prefix,
|
||||
));
|
||||
lines.extend(prefix_lines(cmd_lines, initial_prefix, subsequent_prefix));
|
||||
|
||||
PlainHistoryCell { lines }
|
||||
}
|
||||
|
||||
@@ -217,7 +217,6 @@ pub async fn run_main(
|
||||
let file_layer = tracing_subscriber::fmt::layer()
|
||||
.with_writer(non_blocking)
|
||||
.with_target(false)
|
||||
.with_span_events(tracing_subscriber::fmt::format::FmtSpan::CLOSE)
|
||||
.with_filter(env_filter());
|
||||
|
||||
if cli.oss {
|
||||
|
||||
@@ -18,6 +18,7 @@ pub enum SlashCommand {
|
||||
Init,
|
||||
Compact,
|
||||
Diff,
|
||||
Undo,
|
||||
Mention,
|
||||
Status,
|
||||
Mcp,
|
||||
@@ -36,6 +37,7 @@ impl SlashCommand {
|
||||
SlashCommand::Compact => "summarize conversation to prevent hitting the context limit",
|
||||
SlashCommand::Quit => "exit Codex",
|
||||
SlashCommand::Diff => "show git diff (including untracked files)",
|
||||
SlashCommand::Undo => "undo the last turn diff applied by Codex",
|
||||
SlashCommand::Mention => "mention a file",
|
||||
SlashCommand::Status => "show current session configuration and token usage",
|
||||
SlashCommand::Model => "choose what model and reasoning effort to use",
|
||||
@@ -63,6 +65,7 @@ impl SlashCommand {
|
||||
| SlashCommand::Approvals
|
||||
| SlashCommand::Logout => false,
|
||||
SlashCommand::Diff
|
||||
| SlashCommand::Undo
|
||||
| SlashCommand::Mention
|
||||
| SlashCommand::Status
|
||||
| SlashCommand::Mcp
|
||||
|
||||
Reference in New Issue
Block a user