diff --git a/caffold/src/agent/codex.rs b/caffold/src/agent/codex.rs index a37adcb4..3d5bb594 100644 --- a/caffold/src/agent/codex.rs +++ b/caffold/src/agent/codex.rs @@ -356,6 +356,10 @@ pub(crate) enum CodexThreadError { InvalidParams(String), #[error("Codex app-server protocol error: {0}")] Protocol(String), + #[error( + "Codex keeps this conversation because a fork of it still reads its history. Delete the fork first." + )] + ReferencedByFork, #[error("Codex app-server {method} request {request_id} timed out after {timeout_ms}ms.")] RequestTimeout { method: &'static str, @@ -390,7 +394,8 @@ impl From for super::AgentError { | CodexThreadError::UpdateFailed(_) | CodexThreadError::InitializationFailed { .. } | CodexThreadError::InvalidParams(_) - | CodexThreadError::Protocol(_) => AgentError::Failed(error.to_string()), + | CodexThreadError::Protocol(_) + | CodexThreadError::ReferencedByFork => AgentError::Failed(error.to_string()), } } } @@ -1798,11 +1803,20 @@ fn classify_json_rpc_error(method: Option<&str>, value: &Value) -> CodexThreadEr CodexThreadError::ThreadUnavailable(message) } else if error.code == -32600 && matches!(method, Some(TURN_STEER)) { CodexThreadError::TurnUnavailable(message) + } else if error.code == -32600 + && matches!(method, Some(THREAD_DELETE)) + && message.contains(FORK_HISTORY_REFUSAL) + { + CodexThreadError::ReferencedByFork } else { CodexThreadError::Protocol(format!("{message} (code {})", error.code)) } } +/// Codex words this `thread/delete` refusal without giving it a code of its +/// own. +const FORK_HISTORY_REFUSAL: &str = "forked history still references it"; + async fn read_thread_server_stderr( mut lines: Lines>, events: broadcast::Sender, @@ -3249,6 +3263,45 @@ esac )); } + #[test] + fn explains_a_delete_refused_because_a_fork_reads_the_history() { + use crate::agent::AgentError; + + // The refusal as Codex 0.162.1 words it. + let error = classify_json_rpc_error( + Some(THREAD_DELETE), + &json!({ + "code": -32600, + "message": "cannot delete thread 01a12000-0000-7000-8000-000000000001: forked history still references it" + }), + ); + + assert!(matches!(error, CodexThreadError::ReferencedByFork)); + let AgentError::Failed(message) = AgentError::from(error) else { + panic!("expected a failed deletion"); + }; + assert!(message.contains("Delete the fork first.")); + assert!(!message.contains("01a12000-0000-7000-8000-000000000001")); + assert!(!message.contains("-32600")); + } + + #[test] + fn keeps_other_delete_refusals_in_codex_words() { + let error = classify_json_rpc_error( + Some(THREAD_DELETE), + &json!({ + "code": -32600, + "message": "request rejected" + }), + ); + + assert!(matches!( + error, + CodexThreadError::Protocol(message) + if message == "request rejected (code -32600)" + )); + } + #[test] fn keeps_non_thread_request_failures_as_protocol_errors() { let error = classify_json_rpc_error( diff --git a/docs/architecture/codex-app-server.md b/docs/architecture/codex-app-server.md index f6f346f0..4cefe16d 100644 --- a/docs/architecture/codex-app-server.md +++ b/docs/architecture/codex-app-server.md @@ -508,6 +508,10 @@ record, then clears Caffold's ephemeral per-thread resources. The operation does not remove a Git branch or an external worktree. These steps are intentionally not an atomic cross-system transaction; a failed request keeps any remaining local archived record visible so the user can retry. +App-server refuses `thread/delete` while a fork still reads the thread's +history, and marks that refusal only in its message wording. The driver +recognizes the wording and reports that the fork must be deleted first; any +other `thread/delete` refusal shows app-server's own message. ## Browser Status Projection