Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 54 additions & 1 deletion caffold/src/agent/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -390,7 +394,8 @@ impl From<CodexThreadError> 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()),
}
}
}
Expand Down Expand Up @@ -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<R>(
mut lines: Lines<BufReader<R>>,
events: broadcast::Sender<CodexRuntimeEvent>,
Expand Down Expand Up @@ -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(
Expand Down
4 changes: 4 additions & 0 deletions docs/architecture/codex-app-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading