Skip to content
Open
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
8 changes: 4 additions & 4 deletions codex-rs/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion codex-rs/acp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ unstable = []
workspace = true

[dependencies]
agent-client-protocol = { version = "0.9.0", features = ["unstable"] }
agent-client-protocol = { version = "0.9.3", features = ["unstable"] }
anyhow = { workspace = true }
codex-core = { workspace = true }
codex-protocol = { path = "../protocol" }
Expand Down
7 changes: 3 additions & 4 deletions codex-rs/acp/docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,7 @@ The `AcpBackend` provides a TUI-compatible interface that wraps `AcpConnection`:
└─────────────────────────┘ └─────────────────────────┘
```

- `AcpBackendConfig`: Configuration for spawning (model, cwd, approval_policy, sandbox_policy, notify, nori_home, history_persistence)
- `AcpBackendConfig`: Configuration for spawning (model, cwd, approval_policy, sandbox_policy, notify, nori_home, history_persistence, resume_session_id)
- `AcpBackend::spawn()`: Creates AcpConnection, session, and starts approval handler task. Uses enhanced error handling to provide actionable error messages on spawn or session creation failure.
- `AcpBackend::submit(Op)`: Translates Codex Ops to ACP actions:
- `Op::UserInput` → ACP `prompt()`
Expand Down Expand Up @@ -684,10 +684,9 @@ Client advertises these capabilities to agents:

The following features are marked with TODO comments in the codebase:

**Resume/Fork Integration (connection.rs:343-350):**
- Accept optional session_id parameter to resume existing sessions
**History-aware Resume/Fork Integration (connection.rs):**
- Load persisted history from Codex rollout format
- Send history to agent via session initialization
- Send history to agents via `session/load` when full transcript replay is needed

**Codex-format History Persistence (connection.rs:385-394):**
- Collect all SessionUpdates during prompts
Expand Down
52 changes: 50 additions & 2 deletions codex-rs/acp/src/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@ pub struct AcpBackendConfig {
pub nori_home: PathBuf,
/// History persistence policy
pub history_persistence: crate::config::HistoryPersistence,
/// Optional ACP session ID to resume via `session/resume`.
pub resume_session_id: Option<String>,
}

/// Backend adapter that provides a TUI-compatible interface for ACP agents.
Expand Down Expand Up @@ -223,8 +225,37 @@ impl AcpBackend {
}
};

let resume_session_id = config
.resume_session_id
.as_ref()
.map(|session_id| acp::SessionId::from(session_id.clone()));

// Create a session with enhanced error handling
let session_result = connection.create_session(&cwd).await;
let session_result = if let Some(resume_session_id) = resume_session_id.clone() {
#[cfg(feature = "unstable")]
{
let supports_resume = connection
.capabilities()
.session_capabilities
.resume
.is_some();
if !supports_resume {
return Err(anyhow::anyhow!(
"Agent does not support session/resume. Start a new session or choose a different agent."
));
}
}
#[cfg(not(feature = "unstable"))]
{
return Err(anyhow::anyhow!(
"Session resume is unavailable because unstable ACP features are disabled."
));
}

connection.resume_session(&resume_session_id, &cwd).await
} else {
connection.create_session(&cwd).await
};
let session_id = match session_result {
Ok(id) => id,
Err(e) => {
Expand All @@ -247,7 +278,11 @@ impl AcpBackend {
}
};

debug!("ACP session created: {:?}", session_id);
if resume_session_id.is_some() {
debug!("ACP session resumed: {:?}", session_id);
} else {
debug!("ACP session created: {:?}", session_id);
}

// Take the approval receiver for handling permission requests
let approval_rx = connection.take_approval_receiver();
Expand Down Expand Up @@ -301,6 +336,18 @@ impl AcpBackend {
.await
.ok();

if let Some(resume_session_id) = resume_session_id {
let message = format!(
"Resumed ACP session {resume_session_id}. Previous messages are not shown."
);
let _ = event_tx
.send(Event {
id: String::new(),
msg: EventMsg::Warning(codex_protocol::protocol::WarningEvent { message }),
})
.await;
}

// Spawn approval handler task
tokio::spawn(Self::run_approval_handler(
approval_rx,
Expand Down Expand Up @@ -2269,6 +2316,7 @@ mod tests {
notify: None,
nori_home: temp_dir.path().to_path_buf(),
history_persistence: crate::config::HistoryPersistence::SaveAll,
resume_session_id: None,
};

let result = AcpBackend::spawn(&config, event_tx).await;
Expand Down
59 changes: 51 additions & 8 deletions codex-rs/acp/src/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,11 @@ enum AcpCommand {
cwd: PathBuf,
response_tx: oneshot::Sender<Result<acp::SessionId>>,
},
ResumeSession {
session_id: acp::SessionId,
cwd: PathBuf,
response_tx: oneshot::Sender<Result<acp::SessionId>>,
},
Prompt {
session_id: acp::SessionId,
prompt: Vec<acp::ContentBlock>,
Expand Down Expand Up @@ -258,6 +263,24 @@ impl AcpConnection {
response_rx.await.context("ACP worker thread died")?
}

/// Resume an existing session with the agent.
pub async fn resume_session(
&self,
session_id: &acp::SessionId,
cwd: &Path,
) -> Result<acp::SessionId> {
let (response_tx, response_rx) = oneshot::channel();
self.command_tx
.send(AcpCommand::ResumeSession {
session_id: session_id.clone(),
cwd: cwd.to_path_buf(),
response_tx,
})
.await
.context("ACP worker thread died")?;
response_rx.await.context("ACP worker thread died")?
}

/// Send a prompt to an existing session and receive streaming updates.
///
/// Returns the stop reason when the prompt completes.
Expand Down Expand Up @@ -608,14 +631,6 @@ async fn run_command_loop(
while let Some(cmd) = command_rx.recv().await {
match cmd {
AcpCommand::CreateSession { cwd, response_tx } => {
// TODO: [Future] Resume/Fork Integration
// When creating a session, check if there's an existing session to resume.
// This would require:
// 1. Accepting an optional session_id parameter to resume
// 2. Loading persisted history from Codex rollout format
// 3. Sending history to the agent via the session initialization
// See: codex-core/src/rollout.rs for the persistence format

let result = inner
.connection
.new_session(acp::NewSessionRequest::new(cwd))
Expand All @@ -640,6 +655,34 @@ async fn run_command_loop(
.context("Failed to create ACP session");
let _ = response_tx.send(result);
}
AcpCommand::ResumeSession {
session_id,
cwd,
response_tx,
} => {
let result = inner
.connection
.resume_session(acp::ResumeSessionRequest::new(session_id.clone(), cwd))
.await;

#[cfg(feature = "unstable")]
if let Ok(ref response) = result
&& let Some(ref models) = response.models
&& let Ok(mut state) = model_state.write()
{
*state = AcpModelState::from_session_model_state(models);
debug!(
"Model state updated after resume: current={:?}, available={}",
state.current_model_id,
state.available_models.len()
);
}

let result = result
.map(|_| session_id)
.context("Failed to resume ACP session");
let _ = response_tx.send(result);
}
AcpCommand::Prompt {
session_id,
prompt,
Expand Down
2 changes: 1 addition & 1 deletion codex-rs/mock-acp-agent/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ default = ["unstable"]
unstable = ["agent-client-protocol/unstable"]

[dependencies]
agent-client-protocol = "0.9.0"
agent-client-protocol = "0.9.3"
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["compat"] }
async-trait = "0.1"
Expand Down
5 changes: 5 additions & 0 deletions codex-rs/tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ impl App {
initial_prompt: Option<String>,
initial_images: Vec<PathBuf>,
resume_selection: ResumeSelection,
acp_resume_session_id: Option<String>,
#[cfg(feature = "feedback")] feedback: crate::feedback_compat::CodexFeedback,
) -> Result<AppExitInfo> {
use tokio_stream::StreamExt;
Expand Down Expand Up @@ -302,6 +303,7 @@ impl App {
#[cfg(feature = "feedback")]
feedback: feedback.clone(),
expected_model: None, // No filtering for fresh sessions
acp_resume_session_id: acp_resume_session_id.clone(),
};
ChatWidget::new(init, conversation_manager.clone())
}
Expand All @@ -327,6 +329,7 @@ impl App {
#[cfg(feature = "feedback")]
feedback: feedback.clone(),
expected_model: None, // No filtering for resumed sessions
acp_resume_session_id: None,
};
ChatWidget::new_from_existing(
init,
Expand Down Expand Up @@ -498,6 +501,7 @@ impl App {
#[cfg(feature = "feedback")]
feedback: self.feedback.clone(),
expected_model: None, // No filtering for /new command
acp_resume_session_id: None,
};
self.chat_widget = ChatWidget::new(init, self.server.clone());
if let Some(summary) = summary {
Expand Down Expand Up @@ -1034,6 +1038,7 @@ impl App {
#[cfg(feature = "feedback")]
feedback: self.feedback.clone(),
expected_model: Some(model_name.clone()),
acp_resume_session_id: None,
};
self.chat_widget = ChatWidget::new(init, self.server.clone());

Expand Down
1 change: 1 addition & 0 deletions codex-rs/tui/src/app_backtrack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,7 @@ impl App {
#[cfg(feature = "feedback")]
feedback: self.feedback.clone(),
expected_model: None, // No filtering for backtracked conversations
acp_resume_session_id: None,
};
self.chat_widget =
crate::chatwidget::ChatWidget::new_from_existing(init, conv, session_configured);
Expand Down
11 changes: 10 additions & 1 deletion codex-rs/tui/src/chatwidget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,8 @@ pub(crate) struct ChatWidgetInit {
/// (e.g., from a previous agent) are ignored until SessionConfigured arrives
/// with a matching model. This prevents race conditions when switching agents.
pub(crate) expected_model: Option<String>,
/// ACP-only: optional session ID to resume via `session/resume`.
pub(crate) acp_resume_session_id: Option<String>,
}

#[derive(Default)]
Expand Down Expand Up @@ -1481,10 +1483,16 @@ impl ChatWidget {
#[cfg(feature = "feedback")]
feedback,
expected_model,
acp_resume_session_id,
} = common;
let mut rng = rand::rng();
let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string();
let spawn_result = spawn_agent(config.clone(), app_event_tx.clone(), conversation_manager);
let spawn_result = spawn_agent(
config.clone(),
app_event_tx.clone(),
conversation_manager,
acp_resume_session_id,
);

let mut widget = Self {
app_event_tx: app_event_tx.clone(),
Expand Down Expand Up @@ -1571,6 +1579,7 @@ impl ChatWidget {
#[cfg(feature = "feedback")]
feedback,
expected_model,
acp_resume_session_id: _,
} = common;
let mut rng = rand::rng();
let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string();
Expand Down
10 changes: 8 additions & 2 deletions codex-rs/tui/src/chatwidget/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,12 +95,13 @@ pub(crate) fn spawn_agent(
config: Config,
app_event_tx: AppEventSender,
server: Arc<ConversationManager>,
acp_resume_session_id: Option<String>,
) -> SpawnAgentResult {
let acp_agent_result = get_agent_config(&config.model);

match (acp_agent_result.is_ok(), config.acp_allow_http_fallback) {
// Model is registered in ACP registry -> use ACP
(true, _) => spawn_acp_agent(config, app_event_tx),
(true, _) => spawn_acp_agent(config, app_event_tx, acp_resume_session_id),

// Model NOT registered, but HTTP fallback is allowed -> use HTTP
(false, true) => {
Expand Down Expand Up @@ -156,7 +157,11 @@ fn spawn_error_agent(
///
/// This uses the `codex_acp` crate to spawn an agent subprocess and handle
/// communication via the Agent Client Protocol.
fn spawn_acp_agent(config: Config, app_event_tx: AppEventSender) -> SpawnAgentResult {
fn spawn_acp_agent(
config: Config,
app_event_tx: AppEventSender,
acp_resume_session_id: Option<String>,
) -> SpawnAgentResult {
let (codex_op_tx, mut codex_op_rx) = unbounded_channel::<Op>();

// Create the model command channel for model switching operations
Expand Down Expand Up @@ -184,6 +189,7 @@ fn spawn_acp_agent(config: Config, app_event_tx: AppEventSender) -> SpawnAgentRe
notify: config.notify.clone(),
nori_home,
history_persistence: HistoryPersistence::SaveAll,
resume_session_id: acp_resume_session_id,
};

let backend = match AcpBackend::spawn(&acp_config, event_tx).await {
Expand Down
1 change: 1 addition & 0 deletions codex-rs/tui/src/chatwidget/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,7 @@ async fn helpers_are_available_and_do_not_panic() {
#[cfg(feature = "feedback")]
feedback: crate::feedback_compat::CodexFeedback::new(),
expected_model: None,
acp_resume_session_id: None,
};
let mut w = ChatWidget::new(init, conversation_manager);
// Basic construction sanity.
Expand Down
Loading
Loading