agent: Upgrade ACP SDK to 3.3 (#65414)

- Advertise the implemented, now-stable session notice and compaction
capabilities without the ACP beta gate.
- Handle structured error/custom stop reasons.
- Honor the stable compaction chunk contract: a first-seen chunk creates
an in-progress entry, later chunks append in receive order regardless of
existing status, and chunk metadata stays delivery-scoped. Existing
status, error, record metadata and timeline positions are preserved.

Release Notes:

- Enable session notices and compaction for ACP agents.
This commit is contained in:
Ben Brandt 2026-10-09 19:11:32 +00:00 • committed by GitHub
parent 7fb155e9a2
commit 687d662a0b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 334 additions and 173 deletions

13
Cargo.lock generated
View file

@ -327,9 +327,9 @@ dependencies = [
[[package]]
name = "agent-client-protocol"
version = "3.2.0"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0d09e5bd7214b0aa5f0fd3f2eefae4a8c0069fd6a4c1ae4e733d3691e36ec19"
checksum = "02898d848a20eb09b8a392e85bb3a4c79dba01d369758185d102b001362a60da"
dependencies = [
"agent-client-protocol-derive",
"agent-client-protocol-schema",
@ -345,9 +345,9 @@ dependencies = [
[[package]]
name = "agent-client-protocol-derive"
version = "3.2.0"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8b4e3a708345c6c952bd0091abb58f3169e839ee9c7023285a7e22ab96ba4f3"
checksum = "26b57a6a7fae5ebd4368712bfcd0a366a20e1370ac3f3fcd83379b37637501c9"
dependencies = [
"quote",
"syn 3.0.3",
@ -355,9 +355,9 @@ dependencies = [
[[package]]
name = "agent-client-protocol-schema"
version = "1.10.2"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c3ffd489b0e2f96a134d7966c2f43d874d91299e951b66093b91b54b00b7f8f"
checksum = "3d9a84a76a4f44721e0d8dabcd85b219893c43d6171a4a033d84844d85880823"
dependencies = [
"anyhow",
"derive_more",
@ -382,7 +382,6 @@ dependencies = [
"client",
"collections",
"env_logger 0.11.8",
"feature_flags",
"fs",
"futures 0.3.34",
"gpui",

View file

@ -527,7 +527,7 @@ accesskit = { version = "0.24.0", features = ["enumn"] }
accesskit_macos = "0.26.0"
accesskit_unix = "0.22"
accesskit_windows = "0.34"
agent-client-protocol = { version = "=3.2.0", features = ["schemars", "unstable", "unstable_protocol_v2"] }
agent-client-protocol = { version = "=3.3.0", features = ["schemars", "unstable", "unstable_protocol_v2"] }
aho-corasick = "1.1"
alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "4c129667ce56611becdc82de6e28218c80e2e88f" }
any_vec = "0.14"

View file

@ -4226,7 +4226,11 @@ impl AcpThread {
}
self.had_error = matches!(
stop_reason,
Some(acp_v2::StopReason::MaxTokens | acp_v2::StopReason::Refusal)
Some(
acp_v2::StopReason::MaxTokens
| acp_v2::StopReason::Refusal
| acp_v2::StopReason::Error(_)
)
);
// Reported refusal does not authorize deleting agent-owned history.
cx.emit(AcpThreadEvent::Stopped {
@ -5159,8 +5163,7 @@ impl AcpThread {
.rev()
.find_map(|(entry_index, entry)| match entry {
AgentThreadEntry::ContextCompaction(compaction)
if compaction.id.0 == chunk.compaction_id.0
&& compaction.is_in_progress() =>
if compaction.id.0 == chunk.compaction_id.0 =>
{
Some((entry_index, compaction))
}
@ -5170,7 +5173,17 @@ impl AcpThread {
// Chunk metadata is delivery-scoped, not a patch to the compaction record.
compaction.append_summary(chunk.content, &language_registry, cx);
cx.emit(AcpThreadEvent::EntryUpdated(entry_index));
return;
}
let mut compaction = ContextCompaction {
id: ContextCompactionId(chunk.compaction_id.0),
status: ContextCompactionStatus::InProgress,
error: None,
summary: MessageContent::default(),
meta: None,
};
compaction.append_summary(chunk.content, &language_registry, cx);
self.push_entry(AgentThreadEntry::ContextCompaction(compaction), cx);
}
pub fn update_context_compaction(
@ -7624,7 +7637,6 @@ fn update_markdown_in_place(markdown: &Entity<Markdown>, text: &str, cx: &mut Ap
mod tests {
use super::*;
use anyhow::anyhow;
use feature_flags::FeatureFlag as _;
use futures::stream::StreamExt as _;
use futures::{channel::mpsc, future::LocalBoxFuture, select};
use gpui::UpdateGlobal as _;
@ -8094,25 +8106,6 @@ mod tests {
request.await.expect("turn completes");
}
fn enable_acp_beta(cx: &mut TestAppContext) {
cx.update(|cx| {
cx.update_flags(false, vec![AcpBetaFeatureFlag::NAME.to_string()]);
});
}
fn set_acp_beta_override(value: &str, cx: &mut TestAppContext) {
cx.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |content| {
content
.feature_flags
.get_or_insert_default()
.insert(AcpBetaFeatureFlag::NAME.to_string(), value.to_string());
});
});
});
}
fn message_test_image() -> acp_v2::ContentBlock {
acp_v2::ContentBlock::Image(acp_v2::ImageContent::new(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
@ -11062,6 +11055,87 @@ mod tests {
(2, Some(acp_v2::StopReason::MaxTokens)),
]
);
for (wire, expected_had_error) in [
(
json!({
"state": "idle",
"stopReason": "error",
"error": {"code": -32000, "message": "work failed", "data": {"detail": [1, true]}}
}),
true,
),
(json!({"state": "idle", "stopReason": "error"}), true),
(
json!({"state": "idle", "stopReason": "error", "error": null}),
true,
),
(
json!({"state": "idle", "stopReason": "error", "error": {"message": 42}}),
true,
),
(
json!({"state": "idle", "stopReason": "_custom", "detail": {"nested": [null, true]}}),
false,
),
] {
let idle: acp_v2::StateUpdate =
serde_json::from_value(wire.clone()).expect("idle stop payload");
let acp_v2::StateUpdate::Idle(details) = &idle else {
panic!("expected idle");
};
let stop_reason = details.stop_reason.clone().expect("stop reason retained");
if let acp_v2::StopReason::Error(error) = &stop_reason {
assert_eq!(
error
.error
.as_ref()
.map(|error| serde_json::to_value(error).expect("error")),
wire.get("error")
.filter(|error| error.get("code").is_some())
.cloned(),
);
} else {
assert_eq!(
stop_reason,
acp_v2::StopReason::Other(acp_v2::OtherStopReason::new(
"_custom",
std::collections::BTreeMap::from([(
"detail".into(),
json!({"nested": [null, true]}),
)]),
)),
);
}
let previous_stops = stopped.borrow().len();
let generation = thread.update(cx, |thread, cx| {
thread
.update_session_state(
acp_v2::StateUpdate::Running(acp_v2::RunningStateUpdate::new()),
cx,
)
.expect("running");
assert!(!thread.had_error());
thread.update_session_state(idle.clone(), cx).expect("idle");
assert_eq!(thread.foreground_state(), &idle);
assert_eq!(thread.had_error(), expected_had_error);
assert_eq!(thread.entries().len(), 3);
assert!(matches!(
thread.submission(late_id).expect("accepted receipt").state,
SubmissionState::Accepted { echoed: true, .. }
));
assert!(thread.recoverable_submissions().next().is_none());
thread
.update_session_state(idle.clone(), cx)
.expect("duplicate idle");
thread.activity_generation()
});
cx.run_until_parked();
assert_eq!(stopped.borrow().len(), previous_stops + 1);
assert_eq!(
stopped.borrow().last(),
Some(&(generation, Some(stop_reason)))
);
}
assert_eq!(*legacy_errors.borrow(), 0);
thread.update(cx, |thread, cx| {
thread.forget_submission(late_id, cx);
@ -12843,6 +12917,28 @@ mod tests {
.expect("failed to create ACP thread");
thread.update(cx, |thread, cx| {
for text in ["first ", "second"] {
thread
.handle_session_update(
acp_v1::SessionUpdate::CompactionSummaryChunk(
acp_v1::CompactionSummaryChunk::new(
"compaction",
acp_v1::ContentBlock::Text(acp_v1::TextContent::new(text)),
)
.meta(acp_v1::Meta::from_iter([("delivery".into(), true.into())])),
),
cx,
)
.expect("summary chunks can precede the first status update");
}
let [AgentThreadEntry::ContextCompaction(compaction)] = thread.entries.as_slice()
else {
panic!("chunk-first compaction must create one timeline entry");
};
assert_eq!(compaction.status, ContextCompactionStatus::InProgress);
assert!(compaction.meta.is_none());
assert!(thread.to_markdown(cx).contains("first second"));
for summary in ["retained context", "replacement summary"] {
thread
.handle_session_update(
@ -13260,7 +13356,7 @@ mod tests {
);
});
let source = vec![
let mut source = vec![
acp_v2::ContentBlock::Text(acp_v2::TextContent::new("replacement ").meta(
acp_v2::Meta::from_iter([(
"content".into(),
@ -13293,11 +13389,13 @@ mod tests {
),
cx,
);
let late_text = acp_v2::ContentBlock::from("late text");
thread.append_context_compaction_summary(
acp_v2::CompactionSummaryChunk::new("first", "ignored late text".into())
acp_v2::CompactionSummaryChunk::new("first", late_text.clone())
.meta(acp_v2::Meta::from_iter([("delivery".into(), true.into())])),
cx,
);
source.push(late_text);
let Some(AgentThreadEntry::ContextCompaction(first)) = thread.entries.first() else {
panic!("the original timeline position must remain");
};
@ -13319,6 +13417,7 @@ mod tests {
Some(0),
Some(0),
Some(0),
Some(0),
Some(0)
]
);
@ -17059,12 +17158,8 @@ mod tests {
}
#[gpui::test]
async fn test_elicitation_is_available_without_acp_beta_flag(cx: &mut TestAppContext) {
async fn test_elicitation_is_available(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
cx.update_flags(false, vec![]);
});
set_acp_beta_override("off", cx);
let thread = new_test_thread(cx).await;
let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone());
@ -17093,7 +17188,6 @@ mod tests {
#[gpui::test]
async fn test_form_elicitation_accepts_response(cx: &mut TestAppContext) {
init_test(cx);
enable_acp_beta(cx);
let thread = new_test_thread(cx).await;
let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone());
let tool_call_id = acp_v2::ToolCallId::new("tool-1");
@ -17155,7 +17249,6 @@ mod tests {
#[gpui::test]
async fn test_url_elicitation_can_be_completed(cx: &mut TestAppContext) {
init_test(cx);
enable_acp_beta(cx);
let thread = new_test_thread(cx).await;
let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone());
let url_elicitation_id = acp_v2::ElicitationId::new("url-1");
@ -17220,7 +17313,6 @@ mod tests {
#[gpui::test]
async fn test_idle_cancel_cancels_accepted_url_elicitation(cx: &mut TestAppContext) {
init_test(cx);
enable_acp_beta(cx);
let thread = new_test_thread(cx).await;
let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone());
let url_elicitation_id = acp_v2::ElicitationId::new("url-1");
@ -17284,7 +17376,6 @@ mod tests {
#[gpui::test]
async fn test_cancel_accepted_url_elicitation_marks_canceled(cx: &mut TestAppContext) {
init_test(cx);
enable_acp_beta(cx);
let thread = new_test_thread(cx).await;
let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone());
let url_elicitation_id = acp_v2::ElicitationId::new("url-1");
@ -17356,7 +17447,6 @@ mod tests {
cx: &mut TestAppContext,
) {
init_test(cx);
enable_acp_beta(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, [], cx).await;
let prompt_count = Rc::new(RefCell::new(0usize));
@ -17461,7 +17551,6 @@ mod tests {
#[gpui::test]
async fn test_request_scoped_elicitation_store_accepts_response(cx: &mut TestAppContext) {
init_test(cx);
enable_acp_beta(cx);
let store = cx.update(|cx| cx.new(|_| ElicitationStore::default()));
let request: acp_v2::CreateElicitationRequest = serde_json::from_value(serde_json::json!({
"mode": "form",
@ -17537,7 +17626,6 @@ mod tests {
#[gpui::test]
async fn test_request_elicitation_store_ignores_duplicate_response(cx: &mut TestAppContext) {
init_test(cx);
enable_acp_beta(cx);
let store = cx.update(|cx| cx.new(|_| ElicitationStore::default()));
let responded_ids = Rc::new(RefCell::new(Vec::new()));
let _subscription = cx.update(|cx| {
@ -17608,7 +17696,6 @@ mod tests {
#[gpui::test]
async fn test_cancel_session_elicitation_by_id_resolves_cancel(cx: &mut TestAppContext) {
init_test(cx);
enable_acp_beta(cx);
let thread = new_test_thread(cx).await;
let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone());
@ -17646,7 +17733,6 @@ mod tests {
#[gpui::test]
async fn test_cancel_pending_session_elicitation_resolves_cancel(cx: &mut TestAppContext) {
init_test(cx);
enable_acp_beta(cx);
let thread = new_test_thread(cx).await;
let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone());
@ -17710,7 +17796,6 @@ mod tests {
#[gpui::test]
async fn test_prompt_error_cancels_pending_session_elicitation(cx: &mut TestAppContext) {
init_test(cx);
enable_acp_beta(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, [], cx).await;
let elicitation_action = Rc::new(RefCell::new(None));
@ -17765,7 +17850,6 @@ mod tests {
#[gpui::test]
async fn test_max_tokens_cancels_pending_session_elicitation(cx: &mut TestAppContext) {
init_test(cx);
enable_acp_beta(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, [], cx).await;
let elicitation_action = Rc::new(RefCell::new(None));
@ -17820,7 +17904,6 @@ mod tests {
#[gpui::test]
async fn test_cancel_request_scoped_elicitation_resolves_cancel(cx: &mut TestAppContext) {
init_test(cx);
enable_acp_beta(cx);
let store = cx.update(|cx| cx.new(|_| ElicitationStore::default()));
let (elicitation_id, response_task) = store.update(cx, |store, cx| {
@ -17904,7 +17987,6 @@ mod tests {
#[gpui::test]
async fn test_request_elicitation_store_cancel_all_resolves_cancel(cx: &mut TestAppContext) {
init_test(cx);
enable_acp_beta(cx);
let store = cx.update(|cx| cx.new(|_| ElicitationStore::default()));
let response_task = store.update(cx, |store, cx| {
@ -17937,7 +18019,6 @@ mod tests {
cx: &mut TestAppContext,
) {
init_test(cx);
enable_acp_beta(cx);
let store = cx.update(|cx| cx.new(|_| ElicitationStore::default()));
let first_response_task = store.update(cx, |store, cx| {
@ -18001,7 +18082,6 @@ mod tests {
cx: &mut TestAppContext,
) {
init_test(cx);
enable_acp_beta(cx);
let store = cx.update(|cx| cx.new(|_| ElicitationStore::default()));
let url_elicitation_id = acp_v2::ElicitationId::new("url-1");
@ -18113,7 +18193,6 @@ mod tests {
#[gpui::test]
async fn test_request_url_elicitation_store_can_be_completed(cx: &mut TestAppContext) {
init_test(cx);
enable_acp_beta(cx);
let store = cx.update(|cx| cx.new(|_| ElicitationStore::default()));
let url_elicitation_id = acp_v2::ElicitationId::new("url-1");
@ -18184,7 +18263,6 @@ mod tests {
cx: &mut TestAppContext,
) {
init_test(cx);
enable_acp_beta(cx);
let store = cx.update(|cx| cx.new(|_| ElicitationStore::default()));
let url_elicitation_id = acp_v2::ElicitationId::new("url-1");
let responded_ids = Rc::new(RefCell::new(Vec::new()));
@ -18270,7 +18348,6 @@ mod tests {
cx: &mut TestAppContext,
) {
init_test(cx);
enable_acp_beta(cx);
let thread = new_test_thread(cx).await;
let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone());
@ -18318,7 +18395,6 @@ mod tests {
#[gpui::test]
async fn test_session_elicitation_ignores_duplicate_response(cx: &mut TestAppContext) {
init_test(cx);
enable_acp_beta(cx);
let thread = new_test_thread(cx).await;
let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone());
@ -18372,7 +18448,6 @@ mod tests {
#[gpui::test]
async fn test_url_elicitation_rejects_non_browser_urls(cx: &mut TestAppContext) {
init_test(cx);
enable_acp_beta(cx);
let thread = new_test_thread(cx).await;
let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone());

View file

@ -2487,6 +2487,12 @@ fn legacy_native_completion(stop_reason: acp_v2::StopReason) -> Result<acp_v1::P
acp_v2::StopReason::MaxTurnRequests => acp_v1::StopReason::MaxTurnRequests,
acp_v2::StopReason::Refusal => acp_v1::StopReason::Refusal,
acp_v2::StopReason::Cancelled => acp_v1::StopReason::Cancelled,
acp_v2::StopReason::Error(reason) => {
return Err(match reason.error {
Some(error) => anyhow::Error::new(*error),
None => anyhow!("Native agent stopped with an error"),
});
}
_ => anyhow::bail!("Native agent returned an unsupported stop reason"),
};
Ok(acp_v1::PromptResponse::new(stop_reason))
@ -4001,9 +4007,21 @@ mod internal_tests {
assert_eq!(legacy_native_completion(native)?.stop_reason, legacy);
}
assert!(
legacy_native_completion(acp_v2::StopReason::Other("_future".into())).is_err(),
legacy_native_completion(acp_v2::StopReason::Other(acp_v2::OtherStopReason::new(
"_future",
Default::default(),
)))
.is_err(),
"an unsupported reason must not masquerade as successful completion"
);
let error = legacy_native_completion(acp_v2::ErrorStopReason::new().into())
.expect_err("an error without details is not successful completion");
assert_eq!(error.to_string(), "Native agent stopped with an error");
let details = acp_v2::Error::auth_required().data(json!({"hint": "sign in"}));
let error =
legacy_native_completion(acp_v2::ErrorStopReason::new().error(details.clone()).into())
.expect_err("reported error details propagate");
assert_eq!(error.downcast_ref::<acp_v2::Error>(), Some(&details));
Ok(())
}
@ -4297,10 +4315,6 @@ mod internal_tests {
#[gpui::test]
async fn test_compact_prompt_routes_to_manual_compaction(cx: &mut TestAppContext) {
use feature_flags::{
AcpBetaFeatureFlag, FeatureFlag as _, FeatureFlagAppExt as _, FeatureFlagsSettings,
};
let fake = init_test(cx);
let (connection, agent, project, acp_thread) = setup_native_agent_session(cx).await;
let session_id = cx.update(|cx| acp_thread.read(cx).session_id().clone());
@ -4309,16 +4323,6 @@ mod internal_tests {
let old_message_id = ClientUserMessageId::new();
cx.update(|cx| {
cx.update_flags(true, Vec::new());
FeatureFlagsSettings::override_global(
FeatureFlagsSettings {
overrides: HashMap::from_iter([(
AcpBetaFeatureFlag::NAME.into(),
"off".into(),
)]),
},
cx,
);
let path_style = project.read(cx).path_style(cx);
thread.update(cx, |thread, cx| {
thread.set_model(model.clone(), cx);
@ -4334,7 +4338,6 @@ mod internal_tests {
let compact_message_id = ClientUserMessageId::new();
let prompt_task = cx.update(|cx| {
assert!(!cx.has_flag::<AcpBetaFeatureFlag>());
acp_thread::AgentSessionClientUserMessageIds::prompt(
connection.as_ref(),
compact_message_id,

View file

@ -40,7 +40,6 @@ env_logger = { workspace = true, optional = true }
fs.workspace = true
futures.workspace = true
gpui.workspace = true
feature_flags.workspace = true
gpui_tokio = { workspace = true, optional = true }
http_client.workspace = true
indoc = { workspace = true, optional = true }

View file

@ -23,7 +23,6 @@ use agent_client_protocol::{
use anyhow::anyhow;
use async_channel;
use collections::{HashMap, HashSet};
use feature_flags::{AcpBetaFeatureFlag, FeatureFlagAppExt as _};
use futures::channel::mpsc;
use futures::future::Shared;
use futures::{Future, FutureExt as _, StreamExt as _};
@ -762,10 +761,7 @@ pub fn v2_terminal_client_builder(
)
}
fn client_capabilities_for_agent(
agent_id: &AgentId,
beta_features_enabled: bool,
) -> acp::ClientCapabilities {
fn client_capabilities_for_agent(agent_id: &AgentId) -> acp::ClientCapabilities {
let mut meta = acp::Meta::from_iter([
("terminal_output".into(), true.into()),
("terminal-auth".into(), true.into()),
@ -775,15 +771,13 @@ fn client_capabilities_for_agent(
meta.insert(PARAMETERIZED_MODEL_PICKER_META_KEY.into(), true.into());
}
let mut session_capabilities = acp::ClientSessionCapabilities::new().config_options(
acp::SessionConfigOptionsCapabilities::new()
.boolean(acp::BooleanConfigOptionCapabilities::new()),
);
if beta_features_enabled {
session_capabilities = session_capabilities
.compaction(acp::CompactionCapabilities::new())
.notices(acp::NoticeCapabilities::new());
}
let session_capabilities = acp::ClientSessionCapabilities::new()
.config_options(
acp::SessionConfigOptionsCapabilities::new()
.boolean(acp::BooleanConfigOptionCapabilities::new()),
)
.compaction(acp::CompactionCapabilities::new())
.notices(acp::NoticeCapabilities::new());
acp::ClientCapabilities::new()
.fs(acp::FileSystemCapabilities::new()
@ -923,14 +917,10 @@ impl AcpConnection {
}
};
let beta_features_enabled = cx.update(|cx| cx.has_flag::<AcpBetaFeatureFlag>());
let initialize_response = connection
.send_request(
acp::InitializeRequest::new(ProtocolVersion::V1)
.client_capabilities(client_capabilities_for_agent(
&agent_id,
beta_features_enabled,
))
.client_capabilities(client_capabilities_for_agent(&agent_id))
.client_info(
acp::Implementation::new("zed", version)
.title(release_channel.map(ToOwned::to_owned)),
@ -2762,7 +2752,6 @@ mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use super::*;
use feature_flags::{AcpBetaFeatureFlag, FeatureFlag as _};
use settings::Settings as _;
#[test]
@ -3384,21 +3373,16 @@ mod tests {
assert!(harness.received.lock().expect("receive mutex").is_empty());
}
fn init_feature_flags_test(cx: &mut gpui::TestAppContext) {
fn init_settings_test(cx: &mut gpui::TestAppContext) {
cx.update(|cx| {
let mut settings_store = SettingsStore::test(cx);
settings_store.register_setting::<feature_flags::FeatureFlagsSettings>();
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
cx.update_flags(false, vec![]);
});
}
#[gpui::test]
async fn client_capabilities_include_elicitation_without_acp_beta(
cx: &mut gpui::TestAppContext,
) {
init_feature_flags_test(cx);
let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp"), false);
#[test]
fn client_capabilities_include_elicitation() {
let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp"));
let elicitation = capabilities
.elicitation
.expect("elicitation should always be advertised");
@ -3411,10 +3395,7 @@ mod tests {
async fn request_scoped_elicitation_during_auth_uses_connection_store(
cx: &mut gpui::TestAppContext,
) {
init_feature_flags_test(cx);
cx.update(|cx| {
cx.update_flags(false, vec![AcpBetaFeatureFlag::NAME.to_string()]);
});
init_settings_test(cx);
let fs = fs::FakeFs::new(cx.executor());
fs.insert_tree("/", serde_json::json!({ "a": {} })).await;
@ -3520,10 +3501,7 @@ mod tests {
async fn request_scoped_url_elicitation_completion_before_consent_is_ignored(
cx: &mut gpui::TestAppContext,
) {
init_feature_flags_test(cx);
cx.update(|cx| {
cx.update_flags(false, vec![AcpBetaFeatureFlag::NAME.to_string()]);
});
init_settings_test(cx);
let fs = fs::FakeFs::new(cx.executor());
fs.insert_tree("/", serde_json::json!({ "a": {} })).await;
@ -3612,10 +3590,7 @@ mod tests {
#[gpui::test]
async fn request_scoped_elicitation_ignores_open_sessions(cx: &mut gpui::TestAppContext) {
init_feature_flags_test(cx);
cx.update(|cx| {
cx.update_flags(false, vec![AcpBetaFeatureFlag::NAME.to_string()]);
});
init_settings_test(cx);
let fs = fs::FakeFs::new(cx.executor());
fs.insert_tree("/", serde_json::json!({ "a": {} })).await;
@ -3720,7 +3695,7 @@ mod tests {
#[test]
fn cursor_client_capabilities_include_parameterized_model_picker_meta() {
let capabilities = client_capabilities_for_agent(&AgentId::new(CURSOR_ID), false);
let capabilities = client_capabilities_for_agent(&AgentId::new(CURSOR_ID));
let meta = capabilities
.meta
.expect("expected client capabilities meta");
@ -3735,7 +3710,7 @@ mod tests {
#[test]
fn non_cursor_client_capabilities_do_not_include_parameterized_model_picker_meta() {
let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp"), false);
let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp"));
let meta = capabilities
.meta
.expect("expected client capabilities meta");
@ -3745,7 +3720,7 @@ mod tests {
#[test]
fn client_capabilities_include_boolean_config_options() {
let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp"), false);
let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp"));
assert!(
capabilities
@ -3757,30 +3732,25 @@ mod tests {
}
#[test]
fn client_capabilities_gate_compaction_and_notices_with_acp_beta() {
for (beta_enabled, expected_capability) in
[(false, None), (true, Some(serde_json::json!({})))]
{
let capabilities =
client_capabilities_for_agent(&AgentId::new("codex-acp"), beta_enabled);
let capabilities =
serde_json::to_value(capabilities).expect("client capabilities should serialize");
let session = capabilities
.get("session")
.expect("session capabilities should be advertised");
for capability in ["compaction", "notices"] {
assert_eq!(
session.get(capability),
expected_capability.as_ref(),
"{capability} with ACP beta enabled: {beta_enabled}"
);
}
fn client_capabilities_include_compaction_and_notices() {
let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp"));
let capabilities =
serde_json::to_value(capabilities).expect("client capabilities should serialize");
let session = capabilities
.get("session")
.expect("session capabilities should be advertised");
for capability in ["compaction", "notices"] {
assert_eq!(
session.get(capability),
Some(&serde_json::json!({})),
"{capability} should be advertised"
);
}
}
#[gpui::test]
async fn connection_routes_terminal_auth_without_acp_beta(cx: &mut gpui::TestAppContext) {
init_feature_flags_test(cx);
async fn connection_routes_terminal_auth(cx: &mut gpui::TestAppContext) {
init_settings_test(cx);
let fs = fs::FakeFs::new(cx.executor());
fs.insert_tree("/", serde_json::json!({ "project": {} }))
@ -3847,21 +3817,8 @@ mod tests {
.auth_methods = methods;
let terminal_task = cx
.update(|cx| {
cx.update_flags(true, Vec::new());
feature_flags::FeatureFlagsSettings::override_global(
feature_flags::FeatureFlagsSettings {
overrides: HashMap::from_iter([(
AcpBetaFeatureFlag::NAME.into(),
"off".into(),
)]),
},
cx,
);
assert!(!cx.has_flag::<AcpBetaFeatureFlag>());
harness.connection.terminal_auth_task(&method_id, cx)
})
.expect("first-class terminal auth should be routed without ACP beta");
.update(|cx| harness.connection.terminal_auth_task(&method_id, cx))
.expect("first-class terminal auth should be routed");
let terminal_task = terminal_task
.await
.expect("first-class routing should resolve the test agent's external command");
@ -5510,7 +5467,7 @@ exit 7
let response = client_conn
.send_request(
acp::InitializeRequest::new(ProtocolVersion::V1).client_capabilities(
client_capabilities_for_agent(&AgentId::new("fake-agent"), true),
client_capabilities_for_agent(&AgentId::new("fake-agent")),
),
)
.block_task()
@ -6632,6 +6589,10 @@ exit 7
acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(acp::ContentBlock::Text(
acp::TextContent::new(String::from("hi user")),
))),
acp::SessionUpdate::CompactionUpdate(acp::CompactionUpdate::new(
"replayed-compaction",
acp::CompactionStatus::Completed,
)),
];
let session_id = acp_v2::SessionId::new("session-replay");
@ -6667,7 +6628,7 @@ exit 7
assert_eq!(
entries,
vec!["user", "assistant"],
vec!["user", "assistant", "compaction"],
"replayed notifications should be applied to the thread"
);
}

View file

@ -172,6 +172,10 @@ impl From<anyhow::Error> for ThreadError {
&& acp_error.code == acp_v1::ErrorCode::AuthRequired
{
Self::AuthenticationRequired(acp_error.message.clone().into())
} else if let Some(acp_error) = error.downcast_ref::<acp_v2::Error>()
&& acp_error.code == acp_v2::ErrorCode::AuthRequired
{
Self::AuthenticationRequired(acp_error.message.clone().into())
} else if let Some(lm_error) = error.downcast_ref::<LanguageModelCompletionError>() {
use LanguageModelCompletionError::*;
match lm_error {
@ -238,7 +242,12 @@ impl From<anyhow::Error> for ThreadError {
// Extract ACP error code if available
let acp_error_code = error
.downcast_ref::<acp_v1::Error>()
.map(|acp_error| SharedString::from(acp_error.code.to_string()));
.map(|acp_error| SharedString::from(acp_error.code.to_string()))
.or_else(|| {
error
.downcast_ref::<acp_v2::Error>()
.map(|acp_error| SharedString::from(i32::from(acp_error.code).to_string()))
});
Self::Other {
message,
@ -1970,6 +1979,28 @@ impl ConversationView {
);
return;
}
if let Some(acp_v2::StopReason::Error(details)) = stop_reason
&& thread.read(cx).uses_reported_activity()
{
if let Some(active) = self.root_thread_view() {
let error = details
.error
.as_deref()
.map(|error| anyhow::Error::new(error.clone()))
.unwrap_or_else(|| anyhow!("Agent stopped because of an error"));
active.update(cx, |active, cx| {
active.message_queue.pause();
active.handle_thread_error(error, cx);
});
}
self.notify_with_sound(
"Agent stopped because of an error",
IconName::Warning,
window,
cx,
);
return;
}
let sent_queued_message = if let Some(active) = self.root_thread_view() {
active.update(cx, |active, cx| {
@ -3981,7 +4012,7 @@ pub(crate) mod tests {
use agent_servers::FakeAcpAgentServer;
use editor::MultiBufferOffset;
use editor::actions::Paste;
use feature_flags::{AcpBetaFeatureFlag, FeatureFlag as _, FeatureFlagAppExt as _};
use feature_flags::FeatureFlagAppExt as _;
use fs::FakeFs;
use gpui::{ClipboardItem, EventEmitter, TestAppContext, VisualTestContext, point, size};
use parking_lot::Mutex;
@ -4100,9 +4131,6 @@ pub(crate) mod tests {
#[gpui::test]
async fn test_drop_preserves_shared_pending_request_elicitations(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
cx.update_flags(true, vec![AcpBetaFeatureFlag::NAME.to_string()]);
});
let response = Arc::new(Mutex::new(None));
let server = ReleaseRequestElicitationServer {
@ -4152,9 +4180,6 @@ pub(crate) mod tests {
cx: &mut TestAppContext,
) {
init_test(cx);
cx.update(|cx| {
cx.update_flags(true, vec![AcpBetaFeatureFlag::NAME.to_string()]);
});
let response = Arc::new(Mutex::new(None));
let server = ReleaseRequestElicitationServer {
@ -4207,9 +4232,6 @@ pub(crate) mod tests {
cx: &mut TestAppContext,
) {
init_test(cx);
cx.update(|cx| {
cx.update_flags(true, vec![AcpBetaFeatureFlag::NAME.to_string()]);
});
let store = cx.update(|cx| cx.new(|_| ElicitationStore::default()));
let response = Arc::new(Mutex::new(None));
@ -5089,6 +5111,103 @@ pub(crate) mod tests {
});
}
#[gpui::test]
async fn test_reported_error_after_acceptance_pauses_queue(cx: &mut TestAppContext) {
init_test(cx);
let connection = StubAgentConnection::new().with_receipt_submissions(true);
let (conversation_view, cx) =
setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
add_to_workspace(conversation_view.clone(), cx);
let thread_view = active_thread(&conversation_view, cx);
let thread = thread_view.read_with(cx, |view, _| view.thread.clone());
let finish_receipt = connection.defer_next_receipt_response();
message_editor(&conversation_view, cx).update_in(cx, |editor, window, cx| {
editor.set_text("accepted work", window, cx);
});
thread_view.update_in(cx, |view, window, cx| view.send(window, cx));
cx.run_until_parked();
finish_receipt
.send(Ok(acp_v2::PromptResponse::new("accepted-message")))
.expect("pending receipt");
cx.run_until_parked();
let submission_id =
thread_view.read_with(cx, |view, _| view.current_submission.expect("submission"));
thread_view.update_in(cx, |view, window, cx| {
view.add_to_queue(vec!["follow-up".into()], vec![], window, cx);
});
for (details, expected_message, expected_code) in [
(
Some(acp_v2::Error::new(
acp_v2::ErrorCode::AuthRequired.into(),
"Sign in again",
)),
"Sign in again",
None,
),
(
Some(acp_v2::Error::new(-32099, "Backend failed")),
"Backend failed",
Some("-32099"),
),
(None, "Agent stopped because of an error", None),
] {
thread.update(cx, |thread, cx| {
thread
.update_session_state(
acp_v2::StateUpdate::Running(acp_v2::RunningStateUpdate::new()),
cx,
)
.expect("running");
thread
.update_session_state(
acp_v2::StateUpdate::Idle(acp_v2::IdleStateUpdate::new().stop_reason(
acp_v2::StopReason::Error(
acp_v2::ErrorStopReason::new().error(details),
),
)),
cx,
)
.expect("failed work");
});
cx.run_until_parked();
thread_view.read_with(cx, |view, cx| {
match view.thread_error.as_ref().expect("reported error") {
ThreadError::AuthenticationRequired(message) => {
assert_eq!(message.as_ref(), expected_message);
}
ThreadError::Other {
message,
acp_error_code,
} => {
assert!(message.contains(expected_message));
assert_eq!(acp_error_code.as_deref(), expected_code);
}
error => panic!("unexpected error: {error:?}"),
}
assert_eq!(view.message_queue.len(), 1);
assert!(view.message_queue.auto_send_candidate(false).is_none());
assert!(view.turn_fields.turn_started_at.is_none());
let thread = view.thread.read(cx);
assert!(thread.had_error());
assert!(thread.entries().is_empty());
assert!(matches!(
thread
.submission(submission_id)
.expect("accepted work")
.state,
acp_thread::SubmissionState::Accepted { echoed: false, .. }
));
assert_eq!(
thread
.recoverable_submissions()
.map(|(id, _)| id)
.collect::<Vec<_>>(),
[submission_id],
);
});
}
}
#[gpui::test]
async fn test_stale_send_result_preserves_new_prompt(cx: &mut TestAppContext) {
init_test(cx);
@ -5627,9 +5746,6 @@ pub(crate) mod tests {
#[gpui::test]
async fn test_thread_view_seeds_existing_elicitation_form_state(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
cx.update_flags(true, vec![AcpBetaFeatureFlag::NAME.to_string()]);
});
let connection = PreloadedElicitationConnection::default();
let elicitation_id = connection.elicitation_id.clone();

View file

@ -1525,7 +1525,8 @@ impl ThreadView {
Some(
acp_v2::StopReason::MaxTokens
| acp_v2::StopReason::MaxTurnRequests
| acp_v2::StopReason::Refusal,
| acp_v2::StopReason::Refusal
| acp_v2::StopReason::Error(_),
) => "failure",
_ => "unknown",
}
@ -13742,12 +13743,19 @@ mod tests {
use acp_v2::StopReason::*;
for (reason, expected) in [
(None, "unknown"),
(Some(Other("_custom".into())), "unknown"),
(
Some(Other(acp_v2::OtherStopReason::new(
"_custom",
Default::default(),
))),
"unknown",
),
(Some(EndTurn), "success"),
(Some(Cancelled), "cancelled"),
(Some(Refusal), "failure"),
(Some(MaxTokens), "failure"),
(Some(MaxTurnRequests), "failure"),
(Some(Error(acp_v2::ErrorStopReason::new())), "failure"),
] {
assert_eq!(
ThreadView::activity_completion_status(reason.as_ref()),