acp_thread: Share v2 notice definitions (#65241)

Share SDK v2 notice definitions across the existing live session owner
and notice UI. One explicit v1 adapter feeds the same insertion path
used by direct v2 values; production ACP remains v1.

- Preserve all notice fields, opaque future/custom severity strings,
plain text, nested notice metadata, and absent versus empty optional
values.
- Keep monotonically assigned local IDs, duplicate live events,
independent/idempotent dismissal, and the existing notice update/notify
lifecycle.
- Keep notices out of transcript/history/export, title, conversation
metadata, activity/error state, and permissions.
- Keep the existing severity presentation, unknown-severity fallback,
controls, mouse dismissal, and keyboard focus restoration unchanged.
- Preserve the current v1 beta/capability advertisement. V2 notices do
not require client capability advertisement, but this slice adds no v2
wire handler or backend.

## Review guide

- [explicit v1
adapter](7bed6876a0/crates/acp_thread/src/notices.rs (L4))
and [adapter
fixture](7bed6876a0/crates/acp_thread/src/notices.rs (L23)):
direct field mapping without a production serde bridge.
- [shared live-only
insertion](7bed6876a0/crates/acp_thread/src/acp_thread.rs (L3817))
and [live-state and independent-dismissal
regression](7bed6876a0/crates/acp_thread/src/acp_thread.rs (L18441)):
one existing live-only owner and event path for both versions.
- [existing notice
renderer](7bed6876a0/crates/agent_ui/src/ui/session_notice.rs (L13))
and [rendered mouse/keyboard dismissal
regression](7bed6876a0/crates/agent_ui/src/conversation_view.rs (L5337)):
mixed v1/direct-v2 values, complete metadata retention, actual
mouse/keyboard dismissal, and focus restoration.
- [SDK session-isolation/reload
regression](7bed6876a0/crates/agent_servers/src/acp.rs (L5416)):
existing v1 traffic remains scoped to the correct session and notices
are not restored on reload.

Release Notes:

- N/A
This commit is contained in:
Ben Brandt 2026-10-06 16:59:09 +00:00 • committed by GitHub
parent e7526b378a
commit f87356210a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 127 additions and 29 deletions

View file

@ -5,6 +5,7 @@ mod connection;
pub mod content;
mod diff;
mod mention;
pub mod notices;
pub mod prompt_capabilities;
mod submission;
mod terminal;
@ -3368,7 +3369,7 @@ pub struct AcpThread {
provisional_title: Option<SharedString>,
entries: Vec<AgentThreadEntry>,
// Notices stay with the live session, but never enter conversation history or exports.
notices: Vec<(usize, acp_v1::Notice)>,
notices: Vec<(usize, acp_v2::Notice)>,
next_notice_id: usize,
elicitations: ElicitationStore,
permission_requests: IndexMap<PermissionRequestId, PermissionRequest>,
@ -3810,10 +3811,18 @@ impl AcpThread {
&self.entries
}
pub fn notices(&self) -> &[(usize, acp_v1::Notice)] {
pub fn notices(&self) -> &[(usize, acp_v2::Notice)] {
&self.notices
}
pub fn push_notice(&mut self, notice: acp_v2::Notice, cx: &mut Context<Self>) {
let notice_id = self.next_notice_id;
self.next_notice_id += 1;
self.notices.push((notice_id, notice));
cx.emit(AcpThreadEvent::NoticesUpdated);
cx.notify();
}
pub fn dismiss_notice(&mut self, notice_id: usize, cx: &mut Context<Self>) {
let previous_count = self.notices.len();
self.notices.retain(|(id, _)| *id != notice_id);
@ -4287,11 +4296,7 @@ impl AcpThread {
self.update_plan(plan, cx).map_err(acp_v1::Error::from)?;
}
acp_v1::SessionUpdate::Notice(notice) => {
let notice_id = self.next_notice_id;
self.next_notice_id += 1;
self.notices.push((notice_id, notice));
cx.emit(AcpThreadEvent::NoticesUpdated);
cx.notify();
self.push_notice(notices::from_v1(notice).map_err(acp_v1::Error::from)?, cx);
}
acp_v1::SessionUpdate::SessionInfoUpdate(info_update) => {
self.update_session_info(session_info_update_from_v1(info_update), cx);
@ -18463,29 +18468,32 @@ mod tests {
});
let warning =
acp_v1::Notice::new(acp_v1::NoticeSeverity::Warning, "MCP server unavailable")
acp_v2::Notice::new(acp_v2::NoticeSeverity::Warning, "MCP server unavailable")
.description("Continuing without it.");
let notices = vec![
acp_v1::Notice::new(acp_v1::NoticeSeverity::Info, "Using default configuration"),
acp_v2::Notice::new(acp_v2::NoticeSeverity::Info, "Using default configuration"),
warning.clone(),
warning.clone(),
acp_v1::Notice::new(acp_v1::NoticeSeverity::Error, "Optional integration failed"),
acp_v1::Notice::new(
acp_v1::NoticeSeverity::Other("critical".into()),
acp_v2::Notice::new(acp_v2::NoticeSeverity::Error, "Optional integration failed")
.description("")
.meta(acp_v2::Meta::new()),
acp_v2::Notice::new(
acp_v2::NoticeSeverity::Other("critical".into()),
"Future severity",
),
acp_v1::Notice::new(
acp_v1::NoticeSeverity::Other("_custom".into()),
acp_v2::Notice::new(
acp_v2::NoticeSeverity::Other("_custom".into()),
"**Plain text**, not Markdown",
)
.meta(acp_v1::Meta::from_iter([("source".into(), "test".into())])),
.meta(acp_v2::Meta::from_iter([(
"extension".into(),
serde_json::json!({"nested": [null, true, {"value": "retained"}]}),
)])),
];
thread.update(cx, |thread, cx| {
for notice in &notices {
thread
.handle_session_update(acp_v1::SessionUpdate::Notice(notice.clone()), cx)
.expect("notice should be accepted");
thread.push_notice(notice.clone(), cx);
}
assert_eq!(
thread.notices(),
@ -18523,9 +18531,7 @@ mod tests {
thread.dismiss_notice(notice_id, cx);
}
assert!(thread.notices().is_empty());
thread
.handle_session_update(acp_v1::SessionUpdate::Notice(warning.clone()), cx)
.expect("a repeated notice is a new live event");
thread.push_notice(warning.clone(), cx);
thread.dismiss_notice(1, cx);
assert_eq!(thread.notices(), &[(notices.len(), warning)]);
assert!(thread.entries().is_empty());

View file

@ -0,0 +1,78 @@
use agent_client_protocol::schema::{v1 as acp_v1, v2 as acp_v2};
use anyhow::{Result, bail};
pub fn from_v1(notice: acp_v1::Notice) -> Result<acp_v2::Notice> {
let severity = match notice.severity {
acp_v1::NoticeSeverity::Info => acp_v2::NoticeSeverity::Info,
acp_v1::NoticeSeverity::Warning => acp_v2::NoticeSeverity::Warning,
acp_v1::NoticeSeverity::Error => acp_v2::NoticeSeverity::Error,
acp_v1::NoticeSeverity::Other(severity) => acp_v2::NoticeSeverity::Other(severity),
_ => bail!("Unsupported legacy notice severity"),
};
Ok(acp_v2::Notice::new(severity, notice.title)
.description(notice.description)
.meta(notice.meta))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn legacy_notices_preserve_fields_and_unknown_severities() -> Result<()> {
for (legacy, expected) in [
(
acp_v1::Notice::new(acp_v1::NoticeSeverity::Info, "Using default configuration"),
json!({"severity": "info", "title": "Using default configuration"}),
),
(
serde_json::from_value(json!({
"severity": "warning",
"title": "MCP server unavailable",
"description": null,
"_meta": null
}))?,
json!({"severity": "warning", "title": "MCP server unavailable"}),
),
(
acp_v1::Notice::new(acp_v1::NoticeSeverity::Error, "Optional integration failed")
.description("")
.meta(acp_v1::Meta::new()),
json!({
"severity": "error",
"title": "Optional integration failed",
"description": "",
"_meta": {}
}),
),
(
acp_v1::Notice::new(
acp_v1::NoticeSeverity::Other("critical".into()),
"Future severity",
)
.description("**Plain text**, not Markdown.\nWork continues.")
.meta(acp_v1::Meta::from_iter([(
"extension".into(),
json!({"nested": [null, true, {"value": "retained"}]}),
)])),
json!({
"severity": "critical",
"title": "Future severity",
"description": "**Plain text**, not Markdown.\nWork continues.",
"_meta": {"extension": {"nested": [null, true, {"value": "retained"}]}}
}),
),
(
acp_v1::Notice::new(
acp_v1::NoticeSeverity::Other("_custom/β".into()),
"Custom severity",
),
json!({"severity": "_custom/β", "title": "Custom severity"}),
),
] {
assert_eq!(serde_json::to_value(from_v1(legacy)?)?, expected);
}
Ok(())
}
}

View file

@ -5585,7 +5585,7 @@ exit 7
assert_eq!(notice.title, "A current advisory");
assert_eq!(
notice.severity,
acp::NoticeSeverity::Other("_custom".into())
acp_v2::NoticeSeverity::Other("_custom".into())
);
assert_eq!(notice.description, None);
assert_eq!(notice.meta, None);

View file

@ -5349,12 +5349,26 @@ pub(crate) mod tests {
let notice =
acp_v1::Notice::new(acp_v1::NoticeSeverity::Error, "Optional integration failed")
.description("**Plain text**, not Markdown. Work continues.");
let shared_notice = acp_thread::notices::from_v1(notice.clone()).expect("shared notice");
let custom_notice = acp_v2::Notice::new(
acp_v2::NoticeSeverity::Other("_custom/β".into()),
"**Plain text**, not Markdown",
)
.description("")
.meta(acp_v2::Meta::from_iter([(
"extension".into(),
json!({"nested": [null, true, {"value": "retained"}]}),
)]));
thread.update(cx, |thread, cx| {
for _ in 0..2 {
thread
.handle_session_update(acp_v1::SessionUpdate::Notice(notice.clone()), cx)
.expect("notice should be accepted");
}
thread
.handle_session_update(acp_v1::SessionUpdate::Notice(notice), cx)
.expect("notice should be accepted");
thread.push_notice(custom_notice.clone(), cx);
assert_eq!(
thread.notices(),
&[(0, shared_notice), (1, custom_notice.clone())]
);
assert!(thread.to_markdown(cx).is_empty());
});
cx.run_until_parked();
@ -5370,7 +5384,7 @@ pub(crate) mod tests {
cx.simulate_click(dismiss.center(), gpui::Modifiers::default());
cx.run_until_parked();
thread.read_with(cx, |thread, _| {
assert_eq!(thread.notices(), &[(1, notice)]);
assert_eq!(thread.notices(), &[(1, custom_notice)]);
});
assert!(cx.debug_bounds("dismiss-session-notice-0").is_none());

View file

@ -1,4 +1,4 @@
use agent_client_protocol::schema::v1 as acp;
use agent_client_protocol::schema::v2 as acp;
use component::{Component, ComponentScope, example_group_with_title, single_example};
use gpui::{AnyElement, App, ClickEvent, ElementId, Stateful, Window, px};
use ui::{Callout, Color, IconButton, IconName, IconSize, Severity, Tooltip, prelude::*};