diff --git a/agent-client-protocol-schema/Cargo.toml b/agent-client-protocol-schema/Cargo.toml index edd1f2651..463431fdf 100644 --- a/agent-client-protocol-schema/Cargo.toml +++ b/agent-client-protocol-schema/Cargo.toml @@ -32,6 +32,7 @@ unstable = [ "unstable_nes", "unstable_plan_operations", "unstable_session_fork", + "unstable_subagents", "unstable_session_compaction", "unstable_session_notices", "unstable_end_turn_token_usage", @@ -47,6 +48,7 @@ unstable_plan_operations = [] unstable_session_fork = [] unstable_session_compaction = [] unstable_session_notices = [] +unstable_subagents = [] unstable_end_turn_token_usage = [] # Emit `tracing::warn!` events when `VecSkipError` drops a malformed list diff --git a/agent-client-protocol-schema/src/v1/client.rs b/agent-client-protocol-schema/src/v1/client.rs index d06808b30..e3bbc86ba 100644 --- a/agent-client-protocol-schema/src/v1/client.rs +++ b/agent-client-protocol-schema/src/v1/client.rs @@ -6,9 +6,20 @@ use std::{path::PathBuf, sync::Arc}; use derive_more::{Display, From}; +#[cfg(all(feature = "schemars", feature = "unstable_subagents"))] +use schemars::Schema; use serde::{Deserialize, Serialize}; use serde_with::{DefaultOnError, VecSkipError, serde_as, skip_serializing_none}; - +#[cfg(feature = "unstable_subagents")] +use std::collections::BTreeMap; + +#[cfg(feature = "unstable_subagents")] +use super::StopReason; +#[cfg(all( + feature = "unstable_subagents", + feature = "unstable_end_turn_token_usage" +))] +use super::Usage; use super::{ CompleteElicitationNotification, CreateElicitationRequest, CreateElicitationResponse, ElicitationCapabilities, @@ -166,6 +177,494 @@ pub enum SessionUpdate { /// [`ClientSessionCapabilities::compaction`]. #[cfg(feature = "unstable_session_compaction")] CompactionSummaryChunk(CompactionSummaryChunk), + /// **UNSTABLE** + /// + /// This capability is not part of the spec yet, and may be removed or changed at any point. + /// + /// Announces a child session created and owned by this session, or updates + /// that ownership association's metadata. + #[cfg(feature = "unstable_subagents")] + SubagentUpdate(SubagentUpdate), + /// **UNSTABLE** + /// + /// This capability is not part of the spec yet, and may be removed or changed at any point. + /// + /// A message upsert observed in this session's transcript, sent to or + /// received from another session. + #[cfg(feature = "unstable_subagents")] + SessionMessage(SessionMessage), + /// **UNSTABLE** + /// + /// This capability is not part of the spec yet, and may be removed or changed at any point. + /// + /// One content block appended to a sent or received session message. + #[cfg(feature = "unstable_subagents")] + SessionMessageChunk(SessionMessageChunk), +} + +/// **UNSTABLE** +/// +/// This capability is not part of the spec yet, and may be removed or changed at any point. +/// +/// A content block appended to a transcript-local message in arrival order. +/// Endpoints are optional identity metadata, not content patches: omitted or +/// `null` does not clear a known endpoint. Agents SHOULD supply available +/// endpoints on the first event; later events may omit them or enrich missing +/// endpoints. Supplied endpoints must agree with the enclosing transcript. +/// Live IDs refer to known sessions; history may retain unavailable counterparts. +/// Missing identities permit generic inter-session UI, not guessed participants +/// or human authorship. +/// Chunk metadata applies only to that chunk. This does not instruct the Client +/// to deliver content or imply that the recipient processed it. +#[cfg(feature = "unstable_subagents")] +#[serde_as] +#[skip_serializing_none] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct SessionMessageChunk { + /// Identifier of this message within the enclosing session's transcript. + pub message_id: MessageId, + /// Optional sending session identity; omission or `null` retains a known value. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default)] + pub sender_session_id: Option, + /// Optional receiving session identity; omission or `null` retains a known value. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default)] + pub recipient_session_id: Option, + /// A single content block appended to the message. + pub content: ContentBlock, + /// Optional and nullable chunk-scoped metadata; omitted or `null` means none. + /// + /// Implementations MUST NOT make assumptions about values in `_meta`. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default, rename = "_meta")] + pub meta: Option, +} + +#[cfg(feature = "unstable_subagents")] +impl SessionMessageChunk { + /// Builds a single streamed content block without chunk metadata. + #[must_use] + pub fn new(message_id: impl Into, content: ContentBlock) -> Self { + Self { + message_id: message_id.into(), + sender_session_id: None, + recipient_session_id: None, + content, + meta: None, + } + } + + /// Supplies the sending session identity, when known. + #[must_use] + pub fn sender_session_id(mut self, sender_session_id: impl IntoOption) -> Self { + self.sender_session_id = sender_session_id.into_option(); + self + } + + /// Supplies the receiving session identity, when known. + #[must_use] + pub fn recipient_session_id( + mut self, + recipient_session_id: impl IntoOption, + ) -> Self { + self.recipient_session_id = recipient_session_id.into_option(); + self + } + + /// Sets optional chunk-scoped metadata. + #[must_use] + pub fn meta(mut self, meta: impl IntoOption) -> Self { + self.meta = meta.into_option(); + self + } +} + +/// **UNSTABLE** +/// +/// This capability is not part of the spec yet, and may be removed or changed at any point. +/// +/// An upsert of an inter-session message in the enclosing transcript. +/// `messageId` is local to that transcript; separate views may use independent +/// IDs. Endpoints are optional identity metadata, not content patches: omitted +/// or `null` retains a known endpoint. Agents SHOULD supply available endpoints +/// on the first event; later events may omit them or enrich missing endpoints. +/// Supplied endpoints must agree with the enclosing transcript. Live IDs refer +/// to known sessions; history may retain unavailable counterparts. Missing +/// identities permit generic inter-session UI, not guessed participants or +/// human authorship. +/// Omitted `content` and `_meta` leave their stored values unchanged; `null` +/// clears them. A concrete `content` array replaces existing content +/// (`[]` also clears it); later chunks append. This neither changes session +/// ownership nor instructs the Client to deliver content. +#[cfg(feature = "unstable_subagents")] +#[serde_as] +#[skip_serializing_none] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct SessionMessage { + /// Identifier of this message within the enclosing session's transcript. + pub message_id: MessageId, + /// Optional sending session identity; omission or `null` retains a known value. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default)] + pub sender_session_id: Option, + /// Optional receiving session identity; omission or `null` retains a known value. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default)] + pub recipient_session_id: Option, + /// Omitted leaves content unchanged; `null` or `[]` clears it. + /// A non-empty array replaces all content. + #[serde_as(deserialize_as = "DefaultOnError>>")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true, "x-deserialize-skip-invalid-items" = true)))] + #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")] + pub content: MaybeUndefined>, + /// Omitted leaves metadata unchanged; `null` removes it. + /// + /// Implementations MUST NOT make assumptions about values in `_meta`. + #[serde_as(deserialize_as = "DefaultOnError>")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde( + default, + rename = "_meta", + skip_serializing_if = "MaybeUndefined::is_undefined" + )] + pub meta: MaybeUndefined, +} + +#[cfg(feature = "unstable_subagents")] +impl SessionMessage { + /// Builds a message upsert with its transcript-local ID. + #[must_use] + pub fn new(message_id: impl Into) -> Self { + Self { + message_id: message_id.into(), + sender_session_id: None, + recipient_session_id: None, + content: MaybeUndefined::Undefined, + meta: MaybeUndefined::Undefined, + } + } + + /// Supplies the sending session identity, when known. + #[must_use] + pub fn sender_session_id(mut self, sender_session_id: impl IntoOption) -> Self { + self.sender_session_id = sender_session_id.into_option(); + self + } + + /// Supplies the receiving session identity, when known. + #[must_use] + pub fn recipient_session_id( + mut self, + recipient_session_id: impl IntoOption, + ) -> Self { + self.recipient_session_id = recipient_session_id.into_option(); + self + } + + /// Replaces, clears, or omits the complete content patch. + #[must_use] + pub fn content(mut self, content: impl IntoMaybeUndefined>) -> Self { + self.content = content.into_maybe_undefined(); + self + } + + /// Sets, clears, or omits the metadata patch. + #[must_use] + pub fn meta(mut self, meta: impl IntoMaybeUndefined) -> Self { + self.meta = meta.into_maybe_undefined(); + self + } +} + +#[cfg(all(test, feature = "unstable_subagents"))] +mod session_message_tests { + use super::*; + use serde_json::{Value, json}; + + #[test] + fn envelopes_preserve_participants_and_multimodal_content() { + let content = json!([ + {"type": "text", "text": "Please inspect this"}, + {"type": "image", "data": "aGVsbG8=", "mimeType": "image/png"} + ]); + for (transcript, sender, recipient, id) in [ + ("parent", "parent", "child", "sent-1"), + ("child", "parent", "child", "received-9"), + ("child", "child", "parent", "sent-2"), + ] { + let wire = json!({"sessionId": transcript, "update": { + "sessionUpdate": "session_message", "messageId": id, + "senderSessionId": sender, "recipientSessionId": recipient, "content": content + }}); + let decoded: SessionNotification = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!(serde_json::to_value(&decoded).unwrap(), wire); + assert!(matches!(decoded.update, SessionUpdate::SessionMessage(_))); + let message = SessionMessage::new(id) + .sender_session_id(SessionId::new(sender)) + .recipient_session_id(SessionId::new(recipient)) + .content(vec![]); + assert_eq!(serde_json::to_value(message).unwrap()["content"], json!([])); + } + } + + #[test] + fn message_upserts_validate_ids_and_patch_fields() { + let base = json!({"sessionUpdate": "session_message", "messageId": "m1", + "senderSessionId": "parent", "recipientSessionId": "child", "content": []}); + { + let key = "messageId"; + let mut missing = base.clone(); + missing.as_object_mut().unwrap().remove(key); + assert!( + serde_json::from_value::(missing).is_err(), + "{key}" + ); + let mut null = base.clone(); + null[key] = Value::Null; + assert!( + serde_json::from_value::(null).is_err(), + "{key}" + ); + let mut non_string = base.clone(); + non_string[key] = json!(42); + assert!( + serde_json::from_value::(non_string).is_err(), + "{key}" + ); + } + for content in [None, Some(json!({}))] { + let mut wire = base.clone(); + wire.as_object_mut().unwrap().remove("content"); + if let Some(content) = content { + wire["content"] = content; + } + let decoded: SessionUpdate = serde_json::from_value(wire).unwrap(); + let encoded = serde_json::to_value(decoded).unwrap(); + let mut unchanged = base.clone(); + unchanged.as_object_mut().unwrap().remove("content"); + assert_eq!(encoded, unchanged); + } + let clear = SessionMessage::new("m1") + .sender_session_id(SessionId::new("parent")) + .recipient_session_id(SessionId::new("child")) + .content(None) + .meta(None); + let mut clear_wire = base.clone(); + clear_wire["content"] = Value::Null; + clear_wire["_meta"] = Value::Null; + assert_eq!( + serde_json::to_value(&clear).unwrap()["content"], + Value::Null + ); + let clear_update: SessionUpdate = serde_json::from_value(clear_wire.clone()).unwrap(); + assert_eq!(serde_json::to_value(clear_update).unwrap(), clear_wire); + assert!(clear.content.is_null()); + assert!(clear.meta.is_null()); + for meta in [None, Some(Value::Null), Some(json!({"tag": "value"}))] { + let mut wire = base.clone(); + if let Some(meta) = meta { + wire["_meta"] = meta; + } + let decoded: SessionUpdate = serde_json::from_value(wire.clone()).unwrap(); + let encoded = serde_json::to_value(decoded).unwrap(); + assert_eq!(encoded, wire); + } + let metadata_only = json!({"sessionUpdate": "session_message", + "messageId": "m1", "senderSessionId": "parent", + "recipientSessionId": "child", "_meta": {"tag": "value"}}); + let decoded: SessionUpdate = serde_json::from_value(metadata_only.clone()).unwrap(); + assert_eq!(serde_json::to_value(decoded).unwrap(), metadata_only); + let unset = SessionMessage::new("m1"); + assert_eq!( + serde_json::to_value(&unset).unwrap(), + json!({"messageId": "m1"}) + ); + assert!(unset.content.is_undefined()); + assert!(unset.meta.is_undefined()); + let reset = SessionMessage::new("m1").content(vec![]); + assert_eq!(serde_json::to_value(reset).unwrap()["content"], json!([])); + let malformed: SessionMessage = serde_json::from_value(json!({ + "messageId": "m1", "senderSessionId": "parent", "recipientSessionId": "child", + "content": false, "_meta": false + })) + .unwrap(); + assert!(malformed.content.is_undefined()); + assert!(malformed.meta.is_undefined()); + } + + #[test] + fn first_chunk_and_upsert_share_transcript_local_identity() { + for (transcript, id) in [("parent", "sent-1"), ("child", "received-9")] { + let wire = json!({"sessionId": transcript, "update": { + "sessionUpdate": "session_message_chunk", "messageId": id, + "senderSessionId": "parent", "recipientSessionId": "child", + "content": {"type": "text", "text": "first"} + }}); + let decoded: SessionNotification = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!(serde_json::to_value(decoded).unwrap(), wire); + let upsert = SessionMessage::new(id) + .sender_session_id(SessionId::new("parent")) + .recipient_session_id(SessionId::new("child")); + let chunk = SessionMessageChunk::new( + id, + ContentBlock::Text(crate::v1::TextContent::new("first")), + ) + .sender_session_id(SessionId::new("parent")) + .recipient_session_id(SessionId::new("child")); + assert_eq!(chunk.message_id, upsert.message_id); + assert_eq!(chunk.sender_session_id, upsert.sender_session_id); + assert_eq!(chunk.recipient_session_id, upsert.recipient_session_id); + assert_eq!( + serde_json::to_value(SessionUpdate::SessionMessageChunk(chunk)).unwrap(), + wire["update"] + ); + } + let wire = json!({"sessionId": "child", "update": { + "sessionUpdate": "session_message_chunk", "messageId": "received-9", + "senderSessionId": "parent", "recipientSessionId": "child", + "content": {"type": "text", "text": "first"} + }}); + let base = wire["update"].clone(); + for key in ["messageId", "content"] { + let mut missing = base.clone(); + missing.as_object_mut().unwrap().remove(key); + assert!( + serde_json::from_value::(missing).is_err(), + "{key}" + ); + let mut null = base.clone(); + null[key] = Value::Null; + assert!( + serde_json::from_value::(null).is_err(), + "{key}" + ); + let mut non_string = base.clone(); + non_string[key] = json!(42); + assert!( + serde_json::from_value::(non_string).is_err(), + "{key}" + ); + } + for meta in [None, Some(Value::Null), Some(json!({"chunk": true}))] { + let mut value = base.clone(); + if let Some(meta) = meta { + value["_meta"] = meta; + } + let decoded: SessionUpdate = serde_json::from_value(value.clone()).unwrap(); + let encoded = serde_json::to_value(decoded).unwrap(); + if value["_meta"].is_null() { + assert_eq!(encoded, base); + } else { + assert_eq!(encoded, value); + } + } + } + + #[cfg(feature = "schemars")] + #[test] + fn schema_requires_ids_and_chunk_content() { + let schema = serde_json::to_value(schemars::schema_for!(SessionMessage)).unwrap(); + let required = schema["required"].as_array().unwrap(); + assert_eq!(required, &vec![json!("messageId")]); + let chunk = serde_json::to_value(schemars::schema_for!(SessionMessageChunk)).unwrap(); + let required = chunk["required"].as_array().unwrap(); + assert_eq!(required.len(), 2); + assert!(required.contains(&json!("messageId"))); + assert!(required.contains(&json!("content"))); + } + + #[test] + fn endpoints_can_arrive_late_or_be_omitted_from_later_events() { + let block = ContentBlock::Text(crate::v1::TextContent::new("hello")); + let minimal = SessionMessage::new("m1"); + let first = SessionMessageChunk::new("m1", block.clone()); + assert_eq!( + serde_json::to_value(&minimal).unwrap(), + json!({"messageId": "m1"}) + ); + assert_eq!( + serde_json::to_value(&first).unwrap(), + json!({"messageId": "m1", "content": {"type": "text", "text": "hello"}}) + ); + let enriched = SessionMessage::new("m1") + .sender_session_id(SessionId::new("parent")) + .recipient_session_id(SessionId::new("child")); + assert_eq!(enriched.sender_session_id, Some(SessionId::new("parent"))); + assert_eq!(enriched.recipient_session_id, Some(SessionId::new("child"))); + let later = + SessionMessageChunk::new("m1", block).sender_session_id(SessionId::new("parent")); + assert_eq!(later.sender_session_id, Some(SessionId::new("parent"))); + assert_eq!(later.recipient_session_id, None); + for update in [ + SessionUpdate::SessionMessage(minimal), + SessionUpdate::SessionMessageChunk(first), + SessionUpdate::SessionMessage(enriched), + SessionUpdate::SessionMessageChunk(later), + ] { + let wire = serde_json::to_value(&update).unwrap(); + let decoded: SessionUpdate = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!(serde_json::to_value(decoded).unwrap(), wire); + } + } + + #[test] + fn invalid_or_null_endpoints_are_absent_but_message_id_is_required() { + for (kind, content) in [ + ("session_message", None), + ( + "session_message_chunk", + Some(json!({"type": "text", "text": "hello"})), + ), + ] { + let mut base = json!({"sessionUpdate": kind, "messageId": "m1", + "senderSessionId": null, "recipientSessionId": 42}); + if let Some(content) = content { + base["content"] = content; + } + let decoded: SessionUpdate = serde_json::from_value(base.clone()).unwrap(); + let encoded = serde_json::to_value(decoded).unwrap(); + base.as_object_mut().unwrap().remove("senderSessionId"); + base.as_object_mut().unwrap().remove("recipientSessionId"); + assert_eq!(encoded, base); + for bad in [Value::Null, json!(42)] { + let mut invalid = base.clone(); + invalid["messageId"] = bad; + assert!(serde_json::from_value::(invalid).is_err()); + } + } + } +} + +#[cfg(all(test, not(feature = "unstable_subagents")))] +mod disabled_session_message_tests { + use super::*; + use serde_json::json; + + #[test] + fn message_updates_require_subagents_gate() { + for (kind, content) in [ + ("session_message", json!([])), + ( + "session_message_chunk", + json!({"type": "text", "text": "hello"}), + ), + ] { + let wire = json!({"sessionUpdate": kind, "messageId": "m1", "content": content}); + assert!(serde_json::from_value::(wire).is_err()); + } + } } /// **UNSTABLE** @@ -398,42 +897,500 @@ impl CompactionUpdate { /// /// This capability is not part of the spec yet, and may be removed or changed at any point. /// -/// A content block appended to the retained summary of an in-progress -/// compaction. Agents send chunks only after an `in_progress` update and before -/// the terminal update for the same ID. Agents MUST only send this update when -/// the Client advertised [`ClientSessionCapabilities::compaction`]. -#[cfg(feature = "unstable_session_compaction")] +/// A content block appended to the retained summary of an in-progress +/// compaction. Agents send chunks only after an `in_progress` update and before +/// the terminal update for the same ID. Agents MUST only send this update when +/// the Client advertised [`ClientSessionCapabilities::compaction`]. +#[cfg(feature = "unstable_session_compaction")] +#[serde_as] +#[skip_serializing_none] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct CompactionSummaryChunk { + /// ID of the compaction whose summary receives this content. + pub compaction_id: CompactionId, + /// One content block to append. + pub content: ContentBlock, + /// Metadata scoped to this chunk. Omission and `null` both mean absent. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default, rename = "_meta")] + pub meta: Option, +} + +#[cfg(feature = "unstable_session_compaction")] +impl CompactionSummaryChunk { + /// Builds a summary chunk without metadata. + #[must_use] + pub fn new(compaction_id: impl Into, content: ContentBlock) -> Self { + Self { + compaction_id: compaction_id.into(), + content, + meta: None, + } + } + + /// Sets or clears chunk-scoped metadata. + #[must_use] + pub fn meta(mut self, meta: impl IntoOption) -> Self { + self.meta = meta.into_option(); + self + } +} + +/// **UNSTABLE** +/// +/// This capability is not part of the spec yet, and may be removed or changed at any point. +/// +/// Notification that the enclosing parent session created and owns a child session. +/// +/// Later updates modify the existing association's metadata, not its ownership. +/// +/// Sent on the immediate parent session. The first update for an unknown +/// [`SubagentUpdate::session_id`] announces the child and MUST be sent +/// before any live child traffic or live message naming the child as sender +/// or recipient. Parents may message and reuse an announced child across +/// multiple operations. +/// Child events are delivered automatically on the same connection; no child +/// load, resume, or subscription is needed. +/// +/// Only the subagent session ID is required. Omitted patch fields keep their +/// previous values; `null` clears them. Clearing capabilities disables child +/// mutations. Clearing state leaves current activity unset/unconfirmed: it does +/// not imply idle, stop work, or create an `unknown` state snapshot. A concrete +/// state replaces the entire previous state object, not the session. +/// The title and description provide the parent's display metadata for the +/// child. They do not replace the content of individual messages or operations. +#[cfg(feature = "unstable_subagents")] +#[serde_as] +#[skip_serializing_none] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct SubagentUpdate { + /// The opaque session ID identifying the child in all ACP messages. + /// + /// Nested inside `update`; the enclosing notification's `sessionId` identifies + /// the immediate parent, not this child. + pub session_id: SessionId, + /// The parent's human-readable display title for this child. It need not be unique. + /// + /// Omitted means unchanged; `null` clears it. If unset, the Client chooses + /// a fallback presentation. + #[serde_as(deserialize_as = "DefaultOnError>")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")] + pub title: MaybeUndefined, + /// The parent's human-readable description of the child's role or purpose. + /// + /// Omitted means unchanged; `null` clears it. If unset, the Client chooses + /// a fallback presentation. This is current display metadata, not the + /// history of instructions sent to the child. + #[serde_as(deserialize_as = "DefaultOnError>")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")] + pub description: MaybeUndefined, + /// Client-initiated session mutations permitted for this subagent session. + /// + /// Omitted means unchanged; `null` clears the capability set and disables + /// child mutations. If never supplied, no session mutations are permitted. + /// Read-only operations retain their normal protocol semantics and capability + /// requirements. A concrete object replaces the whole capability set. + #[serde_as(deserialize_as = "DefaultOnError>")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")] + pub capabilities: MaybeUndefined, + /// Current state snapshot for the child session. + /// + /// Omitted means unchanged; `null` clears the current activity without + /// asserting idle or sending an `unknown` snapshot. A concrete state + /// replaces the previous state object wholesale. If never supplied, the + /// current activity is unset/unconfirmed. + #[serde_as(deserialize_as = "DefaultOnError>")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")] + pub state: MaybeUndefined, + /// The _meta property is reserved by ACP to allow clients and agents to attach additional + /// metadata to their interactions. Implementations MUST NOT make assumptions about values at + /// these keys. + /// + /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + /// Omitted means unchanged; `null` removes the metadata. + #[serde_as(deserialize_as = "DefaultOnError>")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde( + default, + rename = "_meta", + skip_serializing_if = "MaybeUndefined::is_undefined" + )] + pub meta: MaybeUndefined, +} + +#[cfg(feature = "unstable_subagents")] +impl SubagentUpdate { + /// Builds a subagent upsert with only its required session ID set. + #[must_use] + pub fn new(session_id: impl Into) -> Self { + Self { + session_id: session_id.into(), + title: MaybeUndefined::Undefined, + description: MaybeUndefined::Undefined, + capabilities: MaybeUndefined::Undefined, + state: MaybeUndefined::Undefined, + meta: MaybeUndefined::Undefined, + } + } + + /// Sets, clears, or omits the parent's display title patch. + #[must_use] + pub fn title(mut self, title: impl IntoMaybeUndefined) -> Self { + self.title = title.into_maybe_undefined(); + self + } + + /// Sets, clears, or omits the parent's description patch. + #[must_use] + pub fn description(mut self, description: impl IntoMaybeUndefined) -> Self { + self.description = description.into_maybe_undefined(); + self + } + + /// Replaces, clears, or omits the permitted client-initiated mutations patch. + #[must_use] + pub fn capabilities( + mut self, + capabilities: impl IntoMaybeUndefined, + ) -> Self { + self.capabilities = capabilities.into_maybe_undefined(); + self + } + + /// Replaces, clears, or omits the current activity patch. + #[must_use] + pub fn state(mut self, state: impl IntoMaybeUndefined) -> Self { + self.state = state.into_maybe_undefined(); + self + } + + /// The _meta property is reserved by ACP to allow clients and agents to attach additional + /// metadata to their interactions. Implementations MUST NOT make assumptions about values at + /// these keys. Sets, clears, or omits this metadata patch. + #[must_use] + pub fn meta(mut self, meta: impl IntoMaybeUndefined) -> Self { + self.meta = meta.into_maybe_undefined(); + self + } +} + +/// **UNSTABLE** +/// +/// This capability is not part of the spec yet, and may be removed or changed at any point. +/// +/// Client-initiated session mutations permitted for a specific subagent session. +/// +/// A mutation requires an explicit per-child capability; support for the method +/// on ordinary sessions does not grant support on a child. +#[cfg(feature = "unstable_subagents")] +#[serde_as] +#[skip_serializing_none] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct SubagentSessionCapabilities { + /// Permits the client to cancel this child's current work without ending + /// the session. Omitted or `null` means unsupported; an object (including + /// `{}`) means supported. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default)] + pub cancel: Option, + /// The _meta property is reserved by ACP to allow clients and agents to attach additional + /// metadata to their interactions. Implementations MUST NOT make assumptions about values at + /// these keys. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default)] + #[serde(rename = "_meta")] + pub meta: Option, +} + +#[cfg(feature = "unstable_subagents")] +impl SubagentSessionCapabilities { + /// Builds an empty capability set; cancellation is disabled. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Sets or removes permission to cancel this child's current work. + #[must_use] + pub fn cancel(mut self, cancel: impl IntoOption) -> Self { + self.cancel = cancel.into_option(); + self + } + + /// The _meta property is reserved by ACP to allow clients and agents to attach additional + /// metadata to their interactions. Implementations MUST NOT make assumptions about values at + /// these keys. + #[must_use] + pub fn meta(mut self, meta: impl IntoOption) -> Self { + self.meta = meta.into_option(); + self + } +} + +/// **UNSTABLE** +/// +/// This capability is not part of the spec yet, and may be removed or changed at any point. +/// +/// Capability to cancel work in a subagent session without ending that session. +/// +/// Supplying `{}` advertises support; an omitted or `null` `cancel` does not. +#[cfg(feature = "unstable_subagents")] +#[serde_as] +#[skip_serializing_none] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[non_exhaustive] +pub struct SessionCancelCapabilities { + /// The _meta property is reserved by ACP to allow clients and agents to attach additional + /// metadata to their interactions. Implementations MUST NOT make assumptions about values at + /// these keys. + /// + /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default)] + #[serde(rename = "_meta")] + pub meta: Option, +} + +#[cfg(feature = "unstable_subagents")] +impl SessionCancelCapabilities { + /// Builds an empty capability object advertising cancellation support. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// The _meta property is reserved by ACP to allow clients and agents to attach additional + /// metadata to their interactions. Implementations MUST NOT make assumptions about values at + /// these keys. + /// + /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + #[must_use] + pub fn meta(mut self, meta: impl IntoOption) -> Self { + self.meta = meta.into_option(); + self + } +} + +/// **UNSTABLE** +/// +/// This capability is not part of the spec yet, and may be removed or changed at any point. +/// +/// Current foreground-work state of a reusable child session. +/// +/// Each update is a whole-object snapshot. Idle does not terminate the child; +/// the parent can message it again, transitioning it back to running. +/// Background activity may still emit other session updates while idle. +#[cfg(feature = "unstable_subagents")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "state", rename_all = "snake_case")] +#[non_exhaustive] +pub enum StateUpdate { + /// Foreground work is in progress. + Running(RunningStateUpdate), + /// The child is ready to process another prompt. + Idle(IdleStateUpdate), + /// Foreground work is blocked on user action. + RequiresAction(RequiresActionStateUpdate), + /// The Agent cannot currently determine foreground activity. + /// + /// This replaces previously confirmed activity without ending the work or session. + Unknown(UnknownStateUpdate), + /// Custom or future state. + /// + /// Values beginning with `_` are reserved for implementation-specific + /// extensions. Other unknown values are reserved for future ACP variants. + #[serde(untagged)] + Other(OtherStateUpdate), +} + +/// Foreground work is in progress. +#[cfg(feature = "unstable_subagents")] +#[serde_as] +#[skip_serializing_none] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct RunningStateUpdate { + /// The _meta property is reserved by ACP for additional metadata. + /// Implementations MUST NOT make assumptions about values at these keys. + /// Optional; omitted and `null` mean no metadata for this state snapshot. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default, rename = "_meta")] + pub meta: Option, +} + +#[cfg(feature = "unstable_subagents")] +impl RunningStateUpdate { + /// Builds an empty running state. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Reserved metadata for extensions. + #[must_use] + pub fn meta(mut self, meta: impl IntoOption) -> Self { + self.meta = meta.into_option(); + self + } +} + +/// The child is ready to process another prompt. +#[cfg(feature = "unstable_subagents")] +#[serde_as] +#[skip_serializing_none] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct IdleStateUpdate { + /// Reason foreground work stopped. Optional; omitted or `null` means not reported. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default)] + pub stop_reason: Option, + /// **UNSTABLE** Token usage for completed foreground work. + /// + /// Optional; omitted or `null` means not reported. + #[cfg(feature = "unstable_end_turn_token_usage")] + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default)] + pub usage: Option, + /// The _meta property is reserved by ACP for additional metadata. + /// Implementations MUST NOT make assumptions about values at these keys. + /// Optional; omitted and `null` mean no metadata for this state snapshot. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default, rename = "_meta")] + pub meta: Option, +} + +#[cfg(feature = "unstable_subagents")] +impl IdleStateUpdate { + /// Builds an empty idle state. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Reason foreground work stopped. + #[must_use] + pub fn stop_reason(mut self, stop_reason: impl IntoOption) -> Self { + self.stop_reason = stop_reason.into_option(); + self + } + + /// Token usage for completed foreground work. + #[cfg(feature = "unstable_end_turn_token_usage")] + #[must_use] + pub fn usage(mut self, usage: impl IntoOption) -> Self { + self.usage = usage.into_option(); + self + } + + /// Reserved metadata for extensions. + #[must_use] + pub fn meta(mut self, meta: impl IntoOption) -> Self { + self.meta = meta.into_option(); + self + } +} + +/// Foreground work is blocked on user action. +#[cfg(feature = "unstable_subagents")] +#[serde_as] +#[skip_serializing_none] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct RequiresActionStateUpdate { + /// The _meta property is reserved by ACP for additional metadata. + /// Implementations MUST NOT make assumptions about values at these keys. + /// Optional; omitted and `null` mean no metadata for this state snapshot. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default, rename = "_meta")] + pub meta: Option, +} + +#[cfg(feature = "unstable_subagents")] +impl RequiresActionStateUpdate { + /// Builds an empty requires-action state. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Reserved metadata for extensions. + #[must_use] + pub fn meta(mut self, meta: impl IntoOption) -> Self { + self.meta = meta.into_option(); + self + } +} + +/// **UNSTABLE** +/// +/// This capability is not part of the spec yet, and may be removed or changed at any point. +/// +/// The Agent cannot currently determine foreground activity. +/// +/// Report this when activity becomes unobservable, not merely because the child +/// has been quiet. The Client MUST stop presenting the previous state as confirmed +/// current activity, but may retain it as last known. A later state replaces this +/// snapshot normally. +/// +/// This is not a task outcome or session closure. It does not cancel work, resolve +/// pending requests, or revoke capabilities; capabilities are updated separately. +#[cfg(feature = "unstable_subagents")] #[serde_as] #[skip_serializing_none] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] #[non_exhaustive] -pub struct CompactionSummaryChunk { - /// ID of the compaction whose summary receives this content. - pub compaction_id: CompactionId, - /// One content block to append. - pub content: ContentBlock, - /// Metadata scoped to this chunk. Omission and `null` both mean absent. +pub struct UnknownStateUpdate { + /// The _meta property is reserved by ACP for additional metadata. + /// Implementations MUST NOT make assumptions about values at these keys. + /// Optional; omitted and `null` mean no metadata for this state snapshot. #[serde_as(deserialize_as = "DefaultOnError")] #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] #[serde(default, rename = "_meta")] pub meta: Option, } -#[cfg(feature = "unstable_session_compaction")] -impl CompactionSummaryChunk { - /// Builds a summary chunk without metadata. +#[cfg(feature = "unstable_subagents")] +impl UnknownStateUpdate { + /// Builds an unknown-activity state. #[must_use] - pub fn new(compaction_id: impl Into, content: ContentBlock) -> Self { - Self { - compaction_id: compaction_id.into(), - content, - meta: None, - } + pub fn new() -> Self { + Self::default() } - /// Sets or clears chunk-scoped metadata. + /// Sets optional metadata for this state snapshot. #[must_use] pub fn meta(mut self, meta: impl IntoOption) -> Self { self.meta = meta.into_option(); @@ -441,6 +1398,80 @@ impl CompactionSummaryChunk { } } +/// Custom or future state payload, preserving its discriminator and fields. +#[cfg(feature = "unstable_subagents")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Debug, Clone, Serialize, PartialEq)] +#[cfg_attr(feature = "schemars", schemars(inline))] +#[cfg_attr(feature = "schemars", schemars(transform = other_state_update_schema))] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct OtherStateUpdate { + /// Unrecognized state discriminator. + pub state: String, + /// Remaining fields in the state object. + #[serde(flatten)] + pub fields: BTreeMap, +} + +#[cfg(feature = "unstable_subagents")] +impl OtherStateUpdate { + /// Builds a custom state, preserving its extension fields. + #[must_use] + pub fn new(state: impl Into, mut fields: BTreeMap) -> Self { + fields.remove("state"); + Self { + state: state.into(), + fields, + } + } +} + +#[cfg(feature = "unstable_subagents")] +impl<'de> Deserialize<'de> for OtherStateUpdate { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let mut fields = BTreeMap::::deserialize(deserializer)?; + let state = fields + .remove("state") + .ok_or_else(|| serde::de::Error::missing_field("state"))?; + let serde_json::Value::String(state) = state else { + return Err(serde::de::Error::custom("`state` must be a string")); + }; + if is_known_state_update(&state) { + return Err(serde::de::Error::custom(format!( + "known state update `{state}` did not match its schema" + ))); + } + Ok(Self { state, fields }) + } +} + +#[cfg(feature = "unstable_subagents")] +const KNOWN_STATE_UPDATE_STATES: &[&str] = &["running", "idle", "requires_action", "unknown"]; + +#[cfg(feature = "unstable_subagents")] +fn is_known_state_update(state: &str) -> bool { + KNOWN_STATE_UPDATE_STATES.contains(&state) +} + +#[cfg(all(feature = "unstable_subagents", feature = "schemars"))] +fn other_state_update_schema(schema: &mut Schema) { + let known = KNOWN_STATE_UPDATE_STATES + .iter() + .map(|state| { + serde_json::json!({ + "properties": { "state": { "const": state, "type": "string" } }, + "required": ["state"], + "type": "object" + }) + }) + .collect::>(); + schema.insert("not".into(), serde_json::json!({ "anyOf": known })); +} + /// The current mode of the session has changed /// /// See protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes) @@ -2097,6 +3128,21 @@ pub struct ClientCapabilities { /// /// This capability is not part of the spec yet, and may be removed or changed at any point. /// + /// Whether the client understands exposed subagent sessions. + /// + /// Optional and nullable. Omitted or `null` both mean the client does not + /// advertise support. + /// Supplying `{}` means the client understands child associations, work-state + /// snapshots, session-directed messages, and restricted-session semantics. + #[cfg(feature = "unstable_subagents")] + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default)] + pub subagents: Option, + /// **UNSTABLE** + /// + /// This capability is not part of the spec yet, and may be removed or changed at any point. + /// /// Whether the client supports `plan_update` and `plan_removed` session updates. /// /// Optional. Omitted or `null` both mean the client does not advertise support. @@ -2187,6 +3233,18 @@ impl ClientCapabilities { self } + /// **UNSTABLE** + /// + /// This capability is not part of the spec yet, and may be removed or changed at any point. + /// + /// Whether the client understands exposed subagent sessions. + #[cfg(feature = "unstable_subagents")] + #[must_use] + pub fn subagents(mut self, subagents: impl IntoOption) -> Self { + self.subagents = subagents.into_option(); + self + } + /// **UNSTABLE** /// /// This capability is not part of the spec yet, and may be removed or changed at any point. @@ -2251,6 +3309,51 @@ impl ClientCapabilities { } } +/// **UNSTABLE** +/// +/// This capability is not part of the spec yet, and may be removed or changed at any point. +/// +/// Capability marker for exposing reusable child sessions as restricted ACP sessions. +/// +/// Supplying `{}` advertises support for child association and state updates, +/// session-directed messages, and restricted-session semantics. The client +/// must advertise this capability before the agent sends subagent updates or +/// session-directed messages. +#[cfg(feature = "unstable_subagents")] +#[serde_as] +#[skip_serializing_none] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[non_exhaustive] +pub struct SubagentCapabilities { + /// The _meta property is reserved by ACP to allow clients and agents to attach additional + /// metadata to their interactions. Implementations MUST NOT make assumptions about values at + /// these keys. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default)] + #[serde(rename = "_meta")] + pub meta: Option, +} + +#[cfg(feature = "unstable_subagents")] +impl SubagentCapabilities { + /// Builds an empty capability marker. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// The _meta property is reserved by ACP to allow clients and agents to attach additional + /// metadata to their interactions. Implementations MUST NOT make assumptions about values at + /// these keys. + #[must_use] + pub fn meta(mut self, meta: impl IntoOption) -> Self { + self.meta = meta.into_option(); + self + } +} + /// Session-related capabilities supported by the client. #[serde_as] #[skip_serializing_none] @@ -3192,6 +4295,452 @@ mod tests { assert!(null.compaction.is_none()); } + #[cfg(feature = "unstable_subagents")] + #[test] + fn test_subagent_updates_serialization() { + use serde_json::json; + + let announced = SessionUpdate::SubagentUpdate(SubagentUpdate::new("sess_child_1")); + assert_eq!( + serde_json::to_value(&announced).unwrap(), + json!({ + "sessionUpdate": "subagent_update", + "sessionId": "sess_child_1" + }) + ); + assert_eq!( + serde_json::from_value::(serde_json::to_value(&announced).unwrap()) + .unwrap(), + announced + ); + let capable = SubagentUpdate::new("sess_child_1").capabilities( + SubagentSessionCapabilities::new().cancel(SessionCancelCapabilities::new()), + ); + assert_eq!( + serde_json::to_value(capable).unwrap(), + json!({ + "sessionId": "sess_child_1", + "capabilities": {"cancel": {}} + }) + ); + let minimal: SubagentUpdate = + serde_json::from_value(json!({ "sessionId": "sess_child_3" })).unwrap(); + assert!(minimal.capabilities.is_undefined()); + assert!(minimal.state.is_undefined()); + + let nulls: SubagentUpdate = serde_json::from_value(json!({ + "sessionId": "sess_child_3", + "capabilities": null, + "state": null + })) + .unwrap(); + assert!(nulls.capabilities.is_null()); + assert!(nulls.state.is_null()); + assert_eq!( + serde_json::to_value(nulls).unwrap(), + json!({"sessionId": "sess_child_3", "capabilities": null, "state": null}) + ); + } + + #[cfg(feature = "unstable_subagents")] + #[test] + fn test_subagent_display_metadata() { + use serde_json::json; + + let update = SubagentUpdate::new("child") + .title("Test investigator".to_string()) + .description("Investigates platform-specific test failures.".to_string()); + let wire = json!({ + "sessionId": "child", + "title": "Test investigator", + "description": "Investigates platform-specific test failures." + }); + assert_eq!(serde_json::to_value(&update).unwrap(), wire); + assert_eq!( + serde_json::from_value::(wire).unwrap(), + update + ); + + let minimal = SubagentUpdate::new("child"); + assert_eq!( + serde_json::to_value(&minimal).unwrap(), + json!({"sessionId": "child"}) + ); + let cleared: SubagentUpdate = serde_json::from_value(json!({ + "sessionId": "child", "title": null, "description": null + })) + .unwrap(); + assert!(cleared.title.is_null()); + assert!(cleared.description.is_null()); + assert_eq!( + serde_json::to_value(cleared).unwrap(), + json!({"sessionId": "child", "title": null, "description": null}) + ); + let invalid: SubagentUpdate = serde_json::from_value(json!({ + "sessionId": "child", "title": false, "description": false + })) + .unwrap(); + assert_eq!(invalid, minimal); + + let title_only: SubagentUpdate = serde_json::from_value(json!({ + "sessionId": "child", "title": "Updated title" + })) + .unwrap(); + assert_eq!( + title_only.title.value().map(String::as_str), + Some("Updated title") + ); + assert!(title_only.description.is_undefined()); + } + + #[cfg(feature = "unstable_subagents")] + #[test] + fn subagent_patch_fields_preserve_omitted_null_and_concrete() { + use serde_json::json; + + let omitted = SubagentUpdate::new("child"); + for field in ["title", "description", "capabilities", "state", "_meta"] { + let wire = json!({"sessionId": "child", field: null}); + let decoded: SubagentUpdate = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!(serde_json::to_value(decoded).unwrap(), wire, "{field}"); + let malformed = json!({"sessionId": "child", field: false}); + let decoded: SubagentUpdate = serde_json::from_value(malformed).unwrap(); + assert_eq!(decoded, omitted, "{field}"); + } + let concrete = json!({ + "sessionId": "child", "title": "Investigator", + "description": "Tests", "capabilities": {"cancel": {}}, + "state": {"state": "running"}, "_meta": {"source": "parent"} + }); + let decoded: SubagentUpdate = serde_json::from_value(concrete.clone()).unwrap(); + assert!(decoded.title.value().is_some()); + assert!(decoded.description.value().is_some()); + assert!(decoded.capabilities.value().is_some()); + assert!(decoded.state.value().is_some()); + assert!(decoded.meta.value().is_some()); + assert_eq!(serde_json::to_value(decoded).unwrap(), concrete); + let cleared = SubagentUpdate::new("child") + .title(None) + .description(None) + .capabilities(None) + .state(None) + .meta(None); + assert_eq!( + serde_json::to_value(cleared).unwrap(), + json!({ + "sessionId": "child", "title": null, "description": null, + "capabilities": null, "state": null, "_meta": null + }) + ); + } + + #[cfg(feature = "unstable_subagents")] + #[test] + fn test_subagent_notification_keeps_parent_and_child_ids_nested() { + use serde_json::json; + + let notification = SessionNotification::new( + "parent", + SessionUpdate::SubagentUpdate(SubagentUpdate::new("child")), + ); + let wire = json!({ + "sessionId": "parent", + "update": { + "sessionUpdate": "subagent_update", + "sessionId": "child" + } + }); + assert_eq!(serde_json::to_value(¬ification).unwrap(), wire); + assert_eq!( + serde_json::from_value::(wire).unwrap(), + notification + ); + // Neither ID can stand in for the other: both nesting levels require one. + for malformed in [ + json!({"sessionId": "parent", "update": {"sessionUpdate": "subagent_update"}}), + json!({"update": {"sessionUpdate": "subagent_update", "sessionId": "child"}}), + ] { + assert!(serde_json::from_value::(malformed).is_err()); + } + } + + #[cfg(feature = "unstable_subagents")] + #[test] + fn test_subagent_states_round_trip() { + use serde_json::json; + + for (wire_state, expected) in [ + ( + json!({"state": "running"}), + StateUpdate::Running(RunningStateUpdate::new()), + ), + ( + json!({"state": "idle", "stopReason": "end_turn"}), + StateUpdate::Idle(IdleStateUpdate::new().stop_reason(StopReason::EndTurn)), + ), + ( + json!({"state": "requires_action"}), + StateUpdate::RequiresAction(RequiresActionStateUpdate::new()), + ), + ( + json!({"state": "unknown"}), + StateUpdate::Unknown(UnknownStateUpdate::new()), + ), + ( + json!({"state": "_waiting", "detail": {"ticket": 7}}), + StateUpdate::Other(OtherStateUpdate::new( + "_waiting", + [("detail".into(), json!({"ticket": 7}))].into(), + )), + ), + ( + json!({"state": "paused", "retryAt": 42}), + StateUpdate::Other(OtherStateUpdate::new( + "paused", + [("retryAt".into(), json!(42))].into(), + )), + ), + ] { + let wire = json!({ + "sessionUpdate": "subagent_update", + "sessionId": "sess_child", + "state": wire_state + }); + let parsed: SessionUpdate = serde_json::from_value(wire.clone()).unwrap(); + let SessionUpdate::SubagentUpdate(update) = &parsed else { + panic!("expected subagent update"); + }; + assert_eq!(update.state.value(), Some(&expected)); + assert_eq!(serde_json::to_value(parsed).unwrap(), wire); + } + + for reason in [ + "end_turn", + "max_tokens", + "max_turn_requests", + "refusal", + "cancelled", + ] { + let state: StateUpdate = serde_json::from_value(json!({ + "state": "idle", "stopReason": reason + })) + .unwrap(); + assert_eq!(serde_json::to_value(state).unwrap()["stopReason"], reason); + } + + // Unknown activity is a replaceable snapshot, not a session outcome. + // Later snapshots still address the same child without stale stop reasons. + for state in [ + StateUpdate::Running(RunningStateUpdate::new()), + StateUpdate::RequiresAction(RequiresActionStateUpdate::new()), + StateUpdate::Unknown(UnknownStateUpdate::new()), + StateUpdate::Running(RunningStateUpdate::new()), + StateUpdate::Idle(IdleStateUpdate::new().stop_reason(StopReason::EndTurn)), + StateUpdate::Unknown(UnknownStateUpdate::new()), + StateUpdate::Running(RunningStateUpdate::new()), + ] { + let update = SubagentUpdate::new("sess_child").state(state.clone()); + let wire = serde_json::to_value(&update).unwrap(); + assert_eq!(wire["sessionId"], "sess_child"); + assert_eq!( + serde_json::from_value::(wire) + .unwrap() + .state, + MaybeUndefined::Value(state) + ); + } + assert_eq!( + serde_json::to_value( + SubagentUpdate::new("sess_child").state(StateUpdate::Idle(IdleStateUpdate::new())) + ) + .unwrap()["state"], + json!({"state": "idle"}) + ); + } + + #[cfg(feature = "unstable_subagents")] + #[test] + fn test_subagent_unknown_activity_metadata_and_capabilities() { + use serde_json::json; + + let update = SubagentUpdate::new("child").state(StateUpdate::Unknown( + UnknownStateUpdate::new().meta( + [("source".into(), json!("worker"))] + .into_iter() + .collect::(), + ), + )); + let wire = json!({ + "sessionId": "child", + "state": { + "state": "unknown", + "_meta": { "source": "worker" } + } + }); + assert_eq!(serde_json::to_value(&update).unwrap(), wire); + assert_eq!( + serde_json::from_value::(wire).unwrap(), + update + ); + // Reporting unknown activity does not send a capability revocation. + assert!(update.capabilities.is_undefined()); + + for meta in [json!(null), json!(false)] { + let state: StateUpdate = serde_json::from_value(json!({ + "state": "unknown", + "_meta": meta + })) + .unwrap(); + assert_eq!(state, StateUpdate::Unknown(UnknownStateUpdate::new())); + assert_eq!( + serde_json::to_value(state).unwrap(), + json!({"state": "unknown"}) + ); + } + // The standard unknown-activity report is recognized, not an extension fallback. + assert!(serde_json::from_value::(json!({"state": "unknown"})).is_err()); + } + + #[cfg(feature = "unstable_subagents")] + #[test] + fn test_subagent_malformed_optional_state() { + use serde_json::json; + + for malformed in [json!("running"), json!({}), json!({"state": 4})] { + assert!(serde_json::from_value::(malformed.clone()).is_err()); + let update: SubagentUpdate = serde_json::from_value(json!({ + "sessionId": "child", "state": malformed + })) + .unwrap(); + assert!(update.state.is_undefined()); + } + let bad_reason: StateUpdate = + serde_json::from_value(json!({"state": "idle", "stopReason": "not_a_reason"})).unwrap(); + assert_eq!(bad_reason, StateUpdate::Idle(IdleStateUpdate::new())); + let null_reason: StateUpdate = + serde_json::from_value(json!({"state": "idle", "stopReason": null})).unwrap(); + assert_eq!(null_reason, StateUpdate::Idle(IdleStateUpdate::new())); + } + + #[cfg(all(feature = "unstable_subagents", feature = "unstable_protocol_v2"))] + #[test] + fn test_subagent_state_v2_wire_parity() { + use serde_json::json; + + for wire in [ + json!({"state": "running"}), + json!({"state": "idle", "stopReason": "cancelled"}), + json!({"state": "requires_action"}), + json!({"state": "unknown"}), + json!({"state": "unknown", "_meta": {"source": "worker"}}), + json!({"state": "_waiting", "detail": {"ticket": 7}}), + ] { + let v1: StateUpdate = serde_json::from_value(wire.clone()).unwrap(); + let v2: crate::v2::StateUpdate = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!(serde_json::to_value(v1).unwrap(), wire); + assert_eq!(serde_json::to_value(v2).unwrap(), wire); + } + } + + #[cfg(feature = "unstable_subagents")] + #[test] + fn test_subagent_capability_semantics() { + use serde_json::json; + + let capabilities = + serde_json::to_value(ClientCapabilities::new().subagents(SubagentCapabilities::new())) + .unwrap(); + assert_eq!(capabilities["subagents"], json!({})); + + let omitted: ClientCapabilities = serde_json::from_value(json!({})).unwrap(); + assert!(omitted.subagents.is_none()); + + let null: ClientCapabilities = + serde_json::from_value(json!({ "subagents": null })).unwrap(); + assert!(null.subagents.is_none()); + assert_eq!(null, omitted); + let serialized = serde_json::to_value(null).unwrap(); + assert_eq!(serialized, serde_json::to_value(omitted).unwrap()); + assert!(serialized.get("subagents").is_none()); + + for wire in [ + json!({}), + json!({"cancel": null}), + json!({"cancel": true}), + json!({"cancel": false}), + ] { + let child: SubagentSessionCapabilities = serde_json::from_value(wire).unwrap(); + assert!(child.cancel.is_none()); + assert_eq!(serde_json::to_value(child).unwrap(), json!({})); + } + let enabled = SubagentSessionCapabilities::new().cancel(SessionCancelCapabilities::new()); + assert_eq!( + serde_json::to_value(&enabled).unwrap(), + json!({"cancel": {}}) + ); + assert_eq!( + serde_json::from_value::(json!({"cancel": {}})).unwrap(), + enabled + ); + let meta: Meta = [("source".into(), json!("worker"))].into_iter().collect(); + let with_meta = + SubagentSessionCapabilities::new().cancel(SessionCancelCapabilities::new().meta(meta)); + let wire = json!({"cancel": {"_meta": {"source": "worker"}}}); + assert_eq!(serde_json::to_value(&with_meta).unwrap(), wire); + assert_eq!( + serde_json::from_value::(wire).unwrap(), + with_meta + ); + // Each supplied child capability set replaces the previous set. + let removed = SubagentSessionCapabilities::new(); + assert_eq!(serde_json::to_value(&removed).unwrap(), json!({})); + assert!(removed.cancel.is_none()); + assert!(enabled.cancel.is_some()); + assert!(enabled.cancel(None).cancel.is_none()); + let replacement = SubagentUpdate::new("child").capabilities(removed); + assert_eq!( + serde_json::to_value(replacement).unwrap(), + json!({"sessionId": "child", "capabilities": {}}) + ); + } + + #[cfg(all(feature = "unstable_subagents", feature = "schemars"))] + #[test] + fn test_subagent_capability_schema_is_optional_and_nullable() { + use serde_json::json; + + let schema = serde_json::to_value(schemars::schema_for!(ClientCapabilities)).unwrap(); + let variants = schema["properties"]["subagents"]["anyOf"] + .as_array() + .expect("subagents must allow the capability object or null"); + assert!(variants.contains(&json!({"type": "null"}))); + assert!( + !schema["required"] + .as_array() + .is_some_and(|required| required.contains(&json!("subagents"))) + ); + let child = + serde_json::to_value(schemars::schema_for!(SubagentSessionCapabilities)).unwrap(); + let variants = child["properties"]["cancel"]["anyOf"] + .as_array() + .expect("cancel must allow the capability object or null"); + assert!(variants.contains(&json!({"type": "null"}))); + assert!( + variants + .iter() + .any(|variant| variant["type"] == "object" || variant.get("$ref").is_some()) + ); + assert!(!variants.iter().any(|variant| variant["type"] == "boolean")); + let cancel = + serde_json::to_value(schemars::schema_for!(SessionCancelCapabilities)).unwrap(); + assert_eq!(cancel["type"], "object"); + assert!( + !child["required"] + .as_array() + .is_some_and(|required| required.contains(&json!("cancel"))) + ); + } + #[test] fn test_elicitation_capability_semantics() { use serde_json::json; diff --git a/agent-client-protocol-schema/src/v2/client.rs b/agent-client-protocol-schema/src/v2/client.rs index b30006e1e..b9d23d7b7 100644 --- a/agent-client-protocol-schema/src/v2/client.rs +++ b/agent-client-protocol-schema/src/v2/client.rs @@ -176,6 +176,29 @@ pub enum SessionUpdate { /// A content block appended to a context compaction's retained summary. #[cfg(feature = "unstable_session_compaction")] CompactionSummaryChunk(CompactionSummaryChunk), + /// **UNSTABLE** + /// + /// This capability is not part of the spec yet, and may be removed or changed at any point. + /// + /// Announces a child session created and owned by this session, or updates + /// that ownership association's metadata. + #[cfg(feature = "unstable_subagents")] + SubagentUpdate(SubagentUpdate), + /// **UNSTABLE** + /// + /// This capability is not part of the spec yet, and may be removed or changed at any point. + /// + /// A message upsert observed in this session's transcript, sent to or + /// received from another session. + #[cfg(feature = "unstable_subagents")] + SessionMessage(SessionMessage), + /// **UNSTABLE** + /// + /// This capability is not part of the spec yet, and may be removed or changed at any point. + /// + /// One content block appended to a sent or received session message. + #[cfg(feature = "unstable_subagents")] + SessionMessageChunk(SessionMessageChunk), /// Custom or future session update. /// /// Values beginning with `_` are reserved for implementation-specific @@ -189,6 +212,432 @@ pub enum SessionUpdate { Other(OtherSessionUpdate), } +/// **UNSTABLE** +/// +/// This capability is not part of the spec yet, and may be removed or changed at any point. +/// +/// A content block appended to a transcript-local message in arrival order. +/// Endpoints are optional identity metadata, not content patches: omitted or +/// `null` does not clear a known endpoint. Agents SHOULD supply available +/// endpoints on the first event; later events may omit them or enrich missing +/// endpoints. Supplied endpoints must agree with the enclosing transcript. +/// Live IDs refer to known sessions; history may retain unavailable counterparts. +/// Missing identities permit generic inter-session UI, not guessed participants +/// or human authorship. +/// Chunk metadata applies only to that chunk. This does not instruct the Client +/// to deliver content or imply that the recipient processed it. +#[cfg(feature = "unstable_subagents")] +#[serde_as] +#[skip_serializing_none] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct SessionMessageChunk { + /// Identifier of this message within the enclosing session's transcript. + pub message_id: MessageId, + /// Optional sending session identity; omission or `null` retains a known value. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default)] + pub sender_session_id: Option, + /// Optional receiving session identity; omission or `null` retains a known value. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default)] + pub recipient_session_id: Option, + /// A single content block appended to the message. + pub content: ContentBlock, + /// Optional and nullable chunk-scoped metadata; omitted or `null` means none. + /// + /// Implementations MUST NOT make assumptions about values in `_meta`. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default, rename = "_meta")] + pub meta: Option, +} + +#[cfg(feature = "unstable_subagents")] +impl SessionMessageChunk { + /// Builds a single streamed content block without chunk metadata. + #[must_use] + pub fn new(message_id: impl Into, content: ContentBlock) -> Self { + Self { + message_id: message_id.into(), + sender_session_id: None, + recipient_session_id: None, + content, + meta: None, + } + } + + /// Supplies the sending session identity, when known. + #[must_use] + pub fn sender_session_id(mut self, sender_session_id: impl IntoOption) -> Self { + self.sender_session_id = sender_session_id.into_option(); + self + } + + /// Supplies the receiving session identity, when known. + #[must_use] + pub fn recipient_session_id( + mut self, + recipient_session_id: impl IntoOption, + ) -> Self { + self.recipient_session_id = recipient_session_id.into_option(); + self + } + + /// Sets optional chunk-scoped metadata. + #[must_use] + pub fn meta(mut self, meta: impl IntoOption) -> Self { + self.meta = meta.into_option(); + self + } +} + +/// **UNSTABLE** +/// +/// This capability is not part of the spec yet, and may be removed or changed at any point. +/// +/// An upsert of an inter-session message in the enclosing transcript. +/// `messageId` is local to that transcript; separate views may use independent +/// IDs. Endpoints are optional identity metadata, not content patches: omitted +/// or `null` retains a known endpoint. Agents SHOULD supply available endpoints +/// on the first event; later events may omit them or enrich missing endpoints. +/// Supplied endpoints must agree with the enclosing transcript. Live IDs refer +/// to known sessions; history may retain unavailable counterparts. Missing +/// identities permit generic inter-session UI, not guessed participants or +/// human authorship. +/// A concrete `content` array replaces existing content; later chunks append. +/// This neither changes session ownership nor instructs the Client to deliver content. +#[cfg(feature = "unstable_subagents")] +#[serde_as] +#[skip_serializing_none] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct SessionMessage { + /// Identifier of this message within the enclosing session's transcript. + pub message_id: MessageId, + /// Optional sending session identity; omission or `null` retains a known value. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default)] + pub sender_session_id: Option, + /// Optional receiving session identity; omission or `null` retains a known value. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default)] + pub recipient_session_id: Option, + /// Omitted leaves content unchanged; `null` clears it; a concrete array + /// replaces the whole content collection. + #[serde_as(deserialize_as = "DefaultOnError>>")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true, "x-deserialize-skip-invalid-items" = true)))] + #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")] + pub content: MaybeUndefined>, + /// Omitted leaves metadata unchanged; `null` clears it. + /// + /// Implementations MUST NOT make assumptions about values in `_meta`. + #[serde_as(deserialize_as = "DefaultOnError>")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde( + default, + rename = "_meta", + skip_serializing_if = "MaybeUndefined::is_undefined" + )] + pub meta: MaybeUndefined, +} + +#[cfg(feature = "unstable_subagents")] +impl SessionMessage { + /// Builds a message upsert with its transcript-local ID. + #[must_use] + pub fn new(message_id: impl Into) -> Self { + Self { + message_id: message_id.into(), + sender_session_id: None, + recipient_session_id: None, + content: MaybeUndefined::Undefined, + meta: MaybeUndefined::Undefined, + } + } + + /// Supplies the sending session identity, when known. + #[must_use] + pub fn sender_session_id(mut self, sender_session_id: impl IntoOption) -> Self { + self.sender_session_id = sender_session_id.into_option(); + self + } + + /// Supplies the receiving session identity, when known. + #[must_use] + pub fn recipient_session_id( + mut self, + recipient_session_id: impl IntoOption, + ) -> Self { + self.recipient_session_id = recipient_session_id.into_option(); + self + } + + /// Replaces, clears, or leaves unchanged the message content. + #[must_use] + pub fn content(mut self, content: impl IntoMaybeUndefined>) -> Self { + self.content = content.into_maybe_undefined(); + self + } + + /// Sets, clears, or leaves unchanged message metadata. + #[must_use] + pub fn meta(mut self, meta: impl IntoMaybeUndefined) -> Self { + self.meta = meta.into_maybe_undefined(); + self + } +} + +#[cfg(all(test, feature = "unstable_subagents"))] +mod session_message_tests { + use super::*; + use serde_json::{Value, json}; + + #[test] + fn transcript_envelopes_and_multimodal_content() { + for (transcript, sender, recipient, id) in [ + ("parent", "parent", "child", "sent-1"), + ("child", "parent", "child", "received-9"), + ("child", "child", "parent", "sent-2"), + ] { + let wire = json!({"sessionId": transcript, "update": { + "sessionUpdate": "session_message", "messageId": id, + "senderSessionId": sender, "recipientSessionId": recipient, "content": [ + {"type": "text", "text": "hello"}, + {"type": "image", "data": "aGVsbG8=", "mimeType": "image/png"} + ] + }}); + let decoded: UpdateSessionNotification = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!(serde_json::to_value(decoded).unwrap(), wire); + } + } + + #[test] + fn chunks_and_upserts_share_transcript_local_identity() { + for (transcript, id) in [("parent", "sent-1"), ("child", "received-9")] { + let wire = json!({"sessionId": transcript, "update": { + "sessionUpdate": "session_message_chunk", "messageId": id, + "senderSessionId": "parent", "recipientSessionId": "child", + "content": {"type": "text", "text": "hello"} + }}); + let decoded: UpdateSessionNotification = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!(serde_json::to_value(decoded).unwrap(), wire); + let upsert = SessionMessage::new(id) + .sender_session_id(SessionId::new("parent")) + .recipient_session_id(SessionId::new("child")) + .content(vec![]); + let chunk = SessionMessageChunk::new( + id, + ContentBlock::Text(crate::v2::TextContent::new("hello")), + ) + .sender_session_id(SessionId::new("parent")) + .recipient_session_id(SessionId::new("child")); + assert_eq!(chunk.message_id, upsert.message_id); + assert_eq!(chunk.sender_session_id, upsert.sender_session_id); + assert_eq!(chunk.recipient_session_id, upsert.recipient_session_id); + assert_eq!( + serde_json::to_value(SessionUpdate::SessionMessageChunk(chunk)).unwrap(), + wire["update"] + ); + assert_eq!(serde_json::to_value(upsert).unwrap()["content"], json!([])); + } + } + + #[test] + fn required_ids_and_chunk_content_with_upsert_patch_semantics() { + for (kind, content) in [ + ("session_message", json!([])), + ( + "session_message_chunk", + json!({"type": "text", "text": "hello"}), + ), + ] { + let base = json!({"sessionUpdate": kind, "messageId": "m1", + "senderSessionId": "parent", "recipientSessionId": "child", "content": content}); + { + let key = "messageId"; + let mut missing = base.clone(); + missing.as_object_mut().unwrap().remove(key); + assert!( + serde_json::from_value::(missing).is_err(), + "{kind} {key}" + ); + let mut null = base.clone(); + null[key] = Value::Null; + assert!( + serde_json::from_value::(null).is_err(), + "{kind} {key}" + ); + let mut non_string = base.clone(); + non_string[key] = json!(42); + assert!( + serde_json::from_value::(non_string).is_err(), + "{kind} {key}" + ); + } + if kind == "session_message_chunk" { + for content in [None, Some(Value::Null), Some(json!([]))] { + let mut invalid = base.clone(); + invalid.as_object_mut().unwrap().remove("content"); + if let Some(content) = content { + invalid["content"] = content; + } + assert!(serde_json::from_value::(invalid).is_err()); + } + } else { + for content in [None, Some(Value::Null), Some(json!([])), Some(json!({}))] { + let mut wire = base.clone(); + wire.as_object_mut().unwrap().remove("content"); + if let Some(content) = content { + wire["content"] = content; + } + let decoded: SessionUpdate = serde_json::from_value(wire.clone()).unwrap(); + let encoded = serde_json::to_value(decoded).unwrap(); + if wire["content"].is_object() { + wire.as_object_mut().unwrap().remove("content"); + } + assert_eq!(encoded, wire); + } + } + for meta in [None, Some(Value::Null), Some(json!({"tag": "value"}))] { + let mut wire = base.clone(); + if let Some(meta) = meta { + wire["_meta"] = meta; + } + let decoded: SessionUpdate = serde_json::from_value(wire.clone()).unwrap(); + let encoded = serde_json::to_value(decoded).unwrap(); + if kind == "session_message" { + assert_eq!(encoded, wire); + } else if wire["_meta"].is_null() { + assert_eq!(encoded, base); + } else { + assert_eq!(encoded, wire); + } + } + } + let metadata_only = json!({"sessionUpdate": "session_message", + "messageId": "m1", "senderSessionId": "parent", + "recipientSessionId": "child", "_meta": {"tag": "value"}}); + let decoded: SessionUpdate = serde_json::from_value(metadata_only.clone()).unwrap(); + assert_eq!(serde_json::to_value(decoded).unwrap(), metadata_only); + let cleared = SessionMessage::new("m1") + .sender_session_id(SessionId::new("parent")) + .recipient_session_id(SessionId::new("child")) + .content(None) + .meta(None); + assert_eq!( + serde_json::to_value(cleared).unwrap(), + json!({"messageId": "m1", "senderSessionId": "parent", + "recipientSessionId": "child", "content": null, "_meta": null}) + ); + } + + #[cfg(feature = "schemars")] + #[test] + fn schemas_require_ids_and_chunk_content_but_not_upsert_content() { + let upsert = serde_json::to_value(schemars::schema_for!(SessionMessage)).unwrap(); + let required = upsert["required"].as_array().unwrap(); + assert_eq!(required, &vec![json!("messageId")]); + let chunk = serde_json::to_value(schemars::schema_for!(SessionMessageChunk)).unwrap(); + let required = chunk["required"].as_array().unwrap(); + assert_eq!(required.len(), 2); + assert!(required.contains(&json!("messageId"))); + assert!(required.contains(&json!("content"))); + } + + #[test] + fn endpoints_can_arrive_late_or_be_omitted_from_later_events() { + let block = ContentBlock::Text(crate::v2::TextContent::new("hello")); + let minimal = SessionMessage::new("m1"); + let first = SessionMessageChunk::new("m1", block.clone()); + assert_eq!( + serde_json::to_value(&minimal).unwrap(), + json!({"messageId": "m1"}) + ); + assert_eq!( + serde_json::to_value(&first).unwrap(), + json!({"messageId": "m1", "content": {"type": "text", "text": "hello"}}) + ); + let enriched = SessionMessage::new("m1") + .sender_session_id(SessionId::new("parent")) + .recipient_session_id(SessionId::new("child")); + assert_eq!(enriched.sender_session_id, Some(SessionId::new("parent"))); + assert_eq!(enriched.recipient_session_id, Some(SessionId::new("child"))); + let later = + SessionMessageChunk::new("m1", block).sender_session_id(SessionId::new("parent")); + assert_eq!(later.sender_session_id, Some(SessionId::new("parent"))); + assert_eq!(later.recipient_session_id, None); + for update in [ + SessionUpdate::SessionMessage(minimal), + SessionUpdate::SessionMessageChunk(first), + SessionUpdate::SessionMessage(enriched), + SessionUpdate::SessionMessageChunk(later), + ] { + let wire = serde_json::to_value(&update).unwrap(); + let decoded: SessionUpdate = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!(serde_json::to_value(decoded).unwrap(), wire); + } + } + + #[test] + fn invalid_or_null_endpoints_are_absent_but_message_id_is_required() { + for (kind, content) in [ + ("session_message", None), + ( + "session_message_chunk", + Some(json!({"type": "text", "text": "hello"})), + ), + ] { + let mut base = json!({"sessionUpdate": kind, "messageId": "m1", + "senderSessionId": null, "recipientSessionId": 42}); + if let Some(content) = content { + base["content"] = content; + } + let decoded: SessionUpdate = serde_json::from_value(base.clone()).unwrap(); + let encoded = serde_json::to_value(decoded).unwrap(); + base.as_object_mut().unwrap().remove("senderSessionId"); + base.as_object_mut().unwrap().remove("recipientSessionId"); + assert_eq!(encoded, base); + for bad in [Value::Null, json!(42)] { + let mut invalid = base.clone(); + invalid["messageId"] = bad; + assert!(serde_json::from_value::(invalid).is_err()); + } + } + } +} + +#[cfg(all(test, not(feature = "unstable_subagents")))] +mod disabled_session_message_tests { + use super::*; + use serde_json::json; + + #[test] + fn preserves_both_unknown_message_discriminators() { + for (kind, content) in [ + ("session_message", json!([])), + ( + "session_message_chunk", + json!({"type": "text", "text": "hello"}), + ), + ] { + let wire = json!({"sessionUpdate": kind, "messageId": "m1", + "senderSessionId": "parent", "recipientSessionId": "child", "content": content}); + let decoded: SessionUpdate = serde_json::from_value(wire.clone()).unwrap(); + assert!(matches!(decoded, SessionUpdate::Other(_))); + assert_eq!(serde_json::to_value(decoded).unwrap(), wire); + } + } +} + /// **UNSTABLE** /// /// This capability is not part of the spec yet, and may be removed or changed at any point. @@ -375,38 +824,290 @@ pub struct CompactionUpdate { pub meta: MaybeUndefined, } -#[cfg(feature = "unstable_session_compaction")] -impl CompactionUpdate { - /// Builds a compaction update with optional patch fields omitted. - #[must_use] - pub fn new(compaction_id: impl Into, status: CompactionStatus) -> Self { - Self { - compaction_id: compaction_id.into(), - status, - summary: MaybeUndefined::Undefined, - error: MaybeUndefined::Undefined, - meta: MaybeUndefined::Undefined, - } - } - - /// Sets, clears, or omits the complete retained summary patch. +#[cfg(feature = "unstable_session_compaction")] +impl CompactionUpdate { + /// Builds a compaction update with optional patch fields omitted. + #[must_use] + pub fn new(compaction_id: impl Into, status: CompactionStatus) -> Self { + Self { + compaction_id: compaction_id.into(), + status, + summary: MaybeUndefined::Undefined, + error: MaybeUndefined::Undefined, + meta: MaybeUndefined::Undefined, + } + } + + /// Sets, clears, or omits the complete retained summary patch. + #[must_use] + pub fn summary(mut self, summary: impl IntoMaybeUndefined>) -> Self { + self.summary = summary.into_maybe_undefined(); + self + } + + /// Sets, clears, or omits the failure description patch. + #[must_use] + pub fn error(mut self, error: impl IntoMaybeUndefined) -> Self { + self.error = error.into_maybe_undefined(); + self + } + + /// Sets, clears, or omits the metadata patch. + #[must_use] + pub fn meta(mut self, meta: impl IntoMaybeUndefined) -> Self { + self.meta = meta.into_maybe_undefined(); + self + } +} + +/// **UNSTABLE** +/// +/// This capability is not part of the spec yet, and may be removed or changed at any point. +/// +/// A content block appended to the retained summary of an in-progress +/// compaction. Agents send chunks only after an `in_progress` update and before +/// the terminal update for the same ID. +#[cfg(feature = "unstable_session_compaction")] +#[serde_as] +#[skip_serializing_none] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct CompactionSummaryChunk { + /// ID of the compaction whose summary receives this content. + pub compaction_id: CompactionId, + /// One content block to append. + pub content: ContentBlock, + /// Metadata scoped to this chunk. Omission and `null` both mean absent. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default, rename = "_meta")] + pub meta: Option, +} + +#[cfg(feature = "unstable_session_compaction")] +impl CompactionSummaryChunk { + /// Builds a summary chunk without metadata. + #[must_use] + pub fn new(compaction_id: impl Into, content: ContentBlock) -> Self { + Self { + compaction_id: compaction_id.into(), + content, + meta: None, + } + } + + /// Sets or clears chunk-scoped metadata. + #[must_use] + pub fn meta(mut self, meta: impl IntoOption) -> Self { + self.meta = meta.into_option(); + self + } +} + +/// **UNSTABLE** +/// +/// This capability is not part of the spec yet, and may be removed or changed at any point. +/// +/// Notification that the enclosing parent session created and owns a child session. +/// +/// Later updates modify the existing association's metadata, not its ownership. +/// +/// Sent on the immediate parent session. The first update for an unknown +/// [`SubagentUpdate::session_id`] announces the child and MUST be sent +/// before any live request or notification bearing the child's session ID, +/// including messages naming it as sender or recipient. Child events are +/// delivered automatically; no separate child load, resume, or subscription is needed. +/// Understanding this update, registering child sessions, and applying their +/// operation restrictions are baseline v2 requirements; no Client capability +/// is required. +/// +/// Only [`SubagentUpdate::session_id`] is required. Other fields have +/// patch semantics: omitted fields leave the stored value unchanged, `null` +/// clears or unsets the value, and concrete values replace it. For `state`, +/// `null` removes the current state report and leaves activity unconfirmed; +/// it does not report idle or cancellation. A child whose capabilities are +/// unset permits no Client-initiated session mutations. +/// +/// The title and description provide the parent's display metadata for the +/// child. Agents SHOULD mirror known child state changes in the parent's +/// [`SubagentUpdate::state`] using the same [`StateUpdate`] snapshot as the +/// ordinary `state_update` notification on the child session. This reports +/// child state, not a separate parent-owned lifecycle. Consumers should +/// treat duplicate reports idempotently. +/// Completing or cancelling work does not end the association: the parent may +/// message the same child again. Individual messages and their outcomes do not +/// change this association. +#[cfg(feature = "unstable_subagents")] +#[serde_as] +#[skip_serializing_none] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct SubagentUpdate { + /// The opaque session ID identifying the child in all ACP messages. + /// + /// Nested inside `update`; the enclosing notification's `sessionId` identifies + /// the immediate parent, not this child. + pub session_id: SessionId, + /// The parent's human-readable display title for this child. It need not be unique. + /// + /// Optional and nullable. Omitted means unchanged; `null` clears it. If no + /// title is set, the Client chooses a fallback presentation. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")] + pub title: MaybeUndefined, + /// The parent's human-readable description of the child's role or purpose. + /// + /// Optional and nullable. Omitted means unchanged; `null` clears it. This is + /// current display metadata, not the history of instructions sent to the child. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")] + pub description: MaybeUndefined, + /// Client-initiated session mutations permitted for this subagent session. + /// + /// Read-only operations retain their normal protocol semantics and + /// capability requirements. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")] + pub capabilities: MaybeUndefined, + /// The child's current foreground state, mirrored onto its parent association. + /// + /// Optional and nullable. Omitted means unchanged; `null` removes the + /// current report and leaves activity unconfirmed (not idle or cancelled). + /// A concrete [`StateUpdate`] replaces the entire previous snapshot. + #[serde_as(deserialize_as = "DefaultOnError>")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")] + pub state: MaybeUndefined, + /// The _meta property is reserved by ACP to allow clients and agents to attach additional + /// metadata to their interactions. Omitted means no metadata update; `null` is an + /// explicit clear signal. Implementations MUST NOT make assumptions about values at these keys. + /// + /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + #[serde_as(deserialize_as = "DefaultOnError>")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde( + rename = "_meta", + default, + skip_serializing_if = "MaybeUndefined::is_undefined" + )] + pub meta: MaybeUndefined, +} + +#[cfg(feature = "unstable_subagents")] +impl SubagentUpdate { + /// Builds a subagent upsert with only its required session ID set. + #[must_use] + pub fn new(session_id: impl Into) -> Self { + Self { + session_id: session_id.into(), + title: MaybeUndefined::Undefined, + description: MaybeUndefined::Undefined, + capabilities: MaybeUndefined::Undefined, + state: MaybeUndefined::Undefined, + meta: MaybeUndefined::Undefined, + } + } + + /// Sets, clears, or leaves unchanged the parent's display title for the child. + #[must_use] + pub fn title(mut self, title: impl IntoMaybeUndefined) -> Self { + self.title = title.into_maybe_undefined(); + self + } + + /// Sets, clears, or leaves unchanged the parent's description of the child. + #[must_use] + pub fn description(mut self, description: impl IntoMaybeUndefined) -> Self { + self.description = description.into_maybe_undefined(); + self + } + + /// Sets, clears, or leaves unchanged the permitted client-initiated session mutations. + #[must_use] + pub fn capabilities( + mut self, + capabilities: impl IntoMaybeUndefined, + ) -> Self { + self.capabilities = capabilities.into_maybe_undefined(); + self + } + + /// Replaces, clears, or leaves unchanged the child's mirrored state snapshot. + #[must_use] + pub fn state(mut self, state: impl IntoMaybeUndefined) -> Self { + self.state = state.into_maybe_undefined(); + self + } + + /// Sets, clears, or leaves unchanged subagent metadata. + #[must_use] + pub fn meta(mut self, meta: impl IntoMaybeUndefined) -> Self { + self.meta = meta.into_maybe_undefined(); + self + } +} + +/// **UNSTABLE** +/// +/// This capability is not part of the spec yet, and may be removed or changed at any point. +/// +/// Client-initiated session mutations permitted for a specific subagent session. +/// +/// A mutation requires an explicit per-child capability; support for the method +/// on ordinary sessions does not grant support on a child. +#[cfg(feature = "unstable_subagents")] +#[serde_as] +#[skip_serializing_none] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct SubagentSessionCapabilities { + /// Permits the client to cancel this child's current work without ending + /// the session. Omitted or `null` means unsupported; an object (including + /// `{}`) means supported. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default)] + pub cancel: Option, + /// The _meta property is reserved by ACP to allow clients and agents to attach additional + /// metadata to their interactions. Implementations MUST NOT make assumptions about values at + /// these keys. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default)] + #[serde(rename = "_meta")] + pub meta: Option, +} + +#[cfg(feature = "unstable_subagents")] +impl SubagentSessionCapabilities { + /// Builds an empty capability set; cancellation is disabled. #[must_use] - pub fn summary(mut self, summary: impl IntoMaybeUndefined>) -> Self { - self.summary = summary.into_maybe_undefined(); - self + pub fn new() -> Self { + Self::default() } - /// Sets, clears, or omits the failure description patch. + /// Sets or removes permission to cancel this child's current work. #[must_use] - pub fn error(mut self, error: impl IntoMaybeUndefined) -> Self { - self.error = error.into_maybe_undefined(); + pub fn cancel(mut self, cancel: impl IntoOption) -> Self { + self.cancel = cancel.into_option(); self } - /// Sets, clears, or omits the metadata patch. + /// The _meta property is reserved by ACP to allow clients and agents to attach additional + /// metadata to their interactions. Implementations MUST NOT make assumptions about values at + /// these keys. #[must_use] - pub fn meta(mut self, meta: impl IntoMaybeUndefined) -> Self { - self.meta = meta.into_maybe_undefined(); + pub fn meta(mut self, meta: impl IntoOption) -> Self { + self.meta = meta.into_option(); self } } @@ -415,41 +1116,41 @@ impl CompactionUpdate { /// /// This capability is not part of the spec yet, and may be removed or changed at any point. /// -/// A content block appended to the retained summary of an in-progress -/// compaction. Agents send chunks only after an `in_progress` update and before -/// the terminal update for the same ID. -#[cfg(feature = "unstable_session_compaction")] +/// Capability to cancel work in a subagent session without ending that session. +/// +/// Supplying `{}` advertises support; an omitted or `null` `cancel` does not. +#[cfg(feature = "unstable_subagents")] #[serde_as] #[skip_serializing_none] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] +#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[non_exhaustive] -pub struct CompactionSummaryChunk { - /// ID of the compaction whose summary receives this content. - pub compaction_id: CompactionId, - /// One content block to append. - pub content: ContentBlock, - /// Metadata scoped to this chunk. Omission and `null` both mean absent. +pub struct SessionCancelCapabilities { + /// The _meta property is reserved by ACP to allow clients and agents to attach additional + /// metadata to their interactions. Implementations MUST NOT make assumptions about values at + /// these keys. + /// + /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) #[serde_as(deserialize_as = "DefaultOnError")] #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] - #[serde(default, rename = "_meta")] + #[serde(default)] + #[serde(rename = "_meta")] pub meta: Option, } -#[cfg(feature = "unstable_session_compaction")] -impl CompactionSummaryChunk { - /// Builds a summary chunk without metadata. +#[cfg(feature = "unstable_subagents")] +impl SessionCancelCapabilities { + /// Builds an empty capability object advertising cancellation support. #[must_use] - pub fn new(compaction_id: impl Into, content: ContentBlock) -> Self { - Self { - compaction_id: compaction_id.into(), - content, - meta: None, - } + pub fn new() -> Self { + Self::default() } - /// Sets or clears chunk-scoped metadata. + /// The _meta property is reserved by ACP to allow clients and agents to attach additional + /// metadata to their interactions. Implementations MUST NOT make assumptions about values at + /// these keys. + /// + /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) #[must_use] pub fn meta(mut self, meta: impl IntoOption) -> Self { self.meta = meta.into_option(); @@ -538,6 +1239,13 @@ fn is_known_session_update(session_update: &str) -> bool { if session_update == "plan_removed" { return true; } + #[cfg(feature = "unstable_subagents")] + if matches!( + session_update, + "subagent_update" | "session_message" | "session_message_chunk" + ) { + return true; + } matches!( session_update, "user_message_chunk" @@ -589,6 +1297,12 @@ fn other_session_update_schema(schema: &mut Schema) { "compaction_update", #[cfg(feature = "unstable_session_compaction")] "compaction_summary_chunk", + #[cfg(feature = "unstable_subagents")] + "subagent_update", + #[cfg(feature = "unstable_subagents")] + "session_message", + #[cfg(feature = "unstable_subagents")] + "session_message_chunk", ], ); } @@ -786,6 +1500,14 @@ pub enum StateUpdate { Idle(IdleStateUpdate), /// Foreground work is blocked on user action. RequiresAction(RequiresActionStateUpdate), + /// **UNSTABLE** + /// + /// This capability is not part of the spec yet, and may be removed or changed at any point. + /// + /// The Agent cannot currently determine foreground activity. + /// This replaces previously confirmed activity without ending the work or session. + #[cfg(feature = "unstable_subagents")] + Unknown(UnknownStateUpdate), /// Custom or future session state. /// /// Values beginning with `_` are reserved for implementation-specific @@ -952,6 +1674,52 @@ impl RequiresActionStateUpdate { } } +/// **UNSTABLE** +/// +/// This capability is not part of the spec yet, and may be removed or changed at any point. +/// +/// The Agent cannot currently determine foreground activity. +/// +/// Report this when activity becomes unobservable, not merely because the session +/// has been quiet. The Client MUST stop presenting the previous state as confirmed +/// current activity, but may retain it as last known. A later state replaces this +/// snapshot normally. +/// +/// This is not a task outcome or session closure. It does not cancel work, resolve +/// pending requests, or revoke capabilities; capabilities are updated separately. +#[cfg(feature = "unstable_subagents")] +#[serde_as] +#[skip_serializing_none] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct UnknownStateUpdate { + /// The _meta property is reserved by ACP for additional metadata. + /// Implementations MUST NOT make assumptions about values at these keys. + /// Optional; omitted and `null` mean no metadata for this state snapshot. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default, rename = "_meta")] + pub meta: Option, +} + +#[cfg(feature = "unstable_subagents")] +impl UnknownStateUpdate { + /// Builds an unknown-activity state. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Sets optional metadata for this state snapshot. + #[must_use] + pub fn meta(mut self, meta: impl IntoOption) -> Self { + self.meta = meta.into_option(); + self + } +} + /// Custom or future session state payload. /// /// This preserves the unknown `state` discriminator and the rest of the state @@ -1010,8 +1778,16 @@ impl<'de> Deserialize<'de> for OtherStateUpdate { } } +const KNOWN_STATE_UPDATE_STATES: &[&str] = &[ + "running", + "idle", + "requires_action", + #[cfg(feature = "unstable_subagents")] + "unknown", +]; + fn is_known_state_update(state: &str) -> bool { - matches!(state, "running" | "idle" | "requires_action") + KNOWN_STATE_UPDATE_STATES.contains(&state) } #[cfg(feature = "schemars")] @@ -1019,7 +1795,7 @@ fn other_state_update_schema(schema: &mut Schema) { super::schema_util::reject_known_string_discriminators( schema, "state", - &["running", "idle", "requires_action"], + KNOWN_STATE_UPDATE_STATES, ); } @@ -2631,6 +3407,413 @@ impl AgentNotification { mod tests { use super::*; + #[cfg(feature = "unstable_subagents")] + #[test] + fn subagent_update_serializes_as_upsert() { + use serde_json::json; + + let announced = + SessionUpdate::SubagentUpdate(SubagentUpdate::new("sess_child_1").capabilities( + SubagentSessionCapabilities::new().cancel(SessionCancelCapabilities::new()), + )); + let wire = json!({ + "sessionUpdate": "subagent_update", + "sessionId": "sess_child_1", + "capabilities": { "cancel": {} } + }); + assert_eq!(serde_json::to_value(&announced).unwrap(), wire); + assert_eq!( + serde_json::from_value::(wire).unwrap(), + announced + ); + + let minimal = SubagentUpdate::new("sess_child_1"); + assert_eq!( + serde_json::to_value(&minimal).unwrap(), + json!({ + "sessionId": "sess_child_1" + }) + ); + assert!(minimal.capabilities.is_undefined()); + assert!(minimal.state.is_undefined()); + assert!(minimal.meta.is_undefined()); + + // Patch semantics distinguish omitted fields from explicit nulls. + let cleared_wire = json!({ + "sessionId": "sess_child_1", + "capabilities": null, + "state": null, + "_meta": null + }); + let cleared: SubagentUpdate = serde_json::from_value(cleared_wire.clone()).unwrap(); + assert!(cleared.capabilities.is_null()); + assert!(cleared.state.is_null()); + assert!(cleared.meta.is_null()); + assert_eq!(serde_json::to_value(cleared).unwrap(), cleared_wire); + + let revoked: SubagentUpdate = serde_json::from_value(json!({ + "sessionId": "sess_child_1", + "capabilities": {} + })) + .unwrap(); + assert_eq!( + revoked.capabilities, + MaybeUndefined::Value(SubagentSessionCapabilities::new()) + ); + assert!( + matches!(revoked.capabilities, MaybeUndefined::Value(ref child) if child.cancel.is_none()) + ); + + assert!( + serde_json::from_value::(json!({ + "sessionUpdate": "subagent_update" + })) + .is_err() + ); + } + + #[cfg(feature = "unstable_subagents")] + #[test] + fn subagent_cancel_capability_is_optional_object() { + use serde_json::json; + + for wire in [ + json!({}), + json!({"cancel": null}), + json!({"cancel": true}), + json!({"cancel": false}), + ] { + let child: SubagentSessionCapabilities = serde_json::from_value(wire).unwrap(); + assert!(child.cancel.is_none()); + assert_eq!(serde_json::to_value(child).unwrap(), json!({})); + } + let enabled = SubagentSessionCapabilities::new().cancel(SessionCancelCapabilities::new()); + assert_eq!( + serde_json::to_value(&enabled).unwrap(), + json!({"cancel": {}}) + ); + assert_eq!( + serde_json::from_value::(json!({"cancel": {}})).unwrap(), + enabled + ); + let meta: Meta = [("source".into(), json!("worker"))].into_iter().collect(); + let with_meta = + SubagentSessionCapabilities::new().cancel(SessionCancelCapabilities::new().meta(meta)); + let wire = json!({"cancel": {"_meta": {"source": "worker"}}}); + assert_eq!(serde_json::to_value(&with_meta).unwrap(), wire); + assert_eq!( + serde_json::from_value::(wire).unwrap(), + with_meta + ); + assert!(enabled.cancel.is_some()); + assert!(enabled.cancel(None).cancel.is_none()); + let removed = SubagentUpdate::new("child").capabilities(SubagentSessionCapabilities::new()); + assert_eq!( + serde_json::to_value(removed).unwrap(), + json!({"sessionId": "child", "capabilities": {}}) + ); + } + + #[cfg(feature = "unstable_subagents")] + #[test] + fn subagent_display_metadata_uses_patch_semantics() { + use serde_json::json; + + let update = SubagentUpdate::new("child") + .title("Test investigator".to_string()) + .description("Investigates platform-specific test failures.".to_string()); + let wire = json!({ + "sessionId": "child", + "title": "Test investigator", + "description": "Investigates platform-specific test failures." + }); + assert_eq!(serde_json::to_value(&update).unwrap(), wire); + assert_eq!( + serde_json::from_value::(wire).unwrap(), + update + ); + + let minimal = SubagentUpdate::new("child"); + assert!(minimal.title.is_undefined()); + assert!(minimal.description.is_undefined()); + assert_eq!( + serde_json::to_value(&minimal).unwrap(), + json!({"sessionId": "child"}) + ); + let cleared_wire = json!({ + "sessionId": "child", "title": null, "description": null + }); + let cleared: SubagentUpdate = serde_json::from_value(cleared_wire.clone()).unwrap(); + assert!(cleared.title.is_null()); + assert!(cleared.description.is_null()); + assert_eq!(serde_json::to_value(cleared).unwrap(), cleared_wire); + + let title_only: SubagentUpdate = serde_json::from_value(json!({ + "sessionId": "child", "title": "Updated title" + })) + .unwrap(); + assert_eq!( + title_only.title, + MaybeUndefined::Value("Updated title".to_string()) + ); + assert!(title_only.description.is_undefined()); + let malformed: SubagentUpdate = serde_json::from_value(json!({ + "sessionId": "child", "title": false, "description": false + })) + .unwrap(); + assert_eq!(malformed, minimal); + } + + #[cfg(feature = "unstable_subagents")] + #[test] + fn subagent_notification_keeps_parent_and_child_ids_nested() { + use serde_json::json; + + let notification = UpdateSessionNotification::new( + "parent", + SessionUpdate::SubagentUpdate(SubagentUpdate::new("child")), + ); + let wire = json!({ + "sessionId": "parent", + "update": { + "sessionUpdate": "subagent_update", + "sessionId": "child" + } + }); + assert_eq!(serde_json::to_value(¬ification).unwrap(), wire); + assert_eq!( + serde_json::from_value::(wire).unwrap(), + notification + ); + for malformed in [ + json!({"sessionId": "parent", "update": {"sessionUpdate": "subagent_update"}}), + json!({"update": {"sessionUpdate": "subagent_update", "sessionId": "child"}}), + ] { + assert!(serde_json::from_value::(malformed).is_err()); + } + } + + #[cfg(feature = "unstable_subagents")] + #[test] + fn subagent_mirrors_normal_child_state_notifications() { + use serde_json::json; + + let association = SubagentUpdate::new("sess_child"); + for state in [ + StateUpdate::Running(RunningStateUpdate::new()), + StateUpdate::RequiresAction(RequiresActionStateUpdate::new()), + StateUpdate::Unknown(UnknownStateUpdate::new()), + StateUpdate::Running(RunningStateUpdate::new()), + StateUpdate::Idle(IdleStateUpdate::new().stop_reason(StopReason::EndTurn)), + StateUpdate::Running(RunningStateUpdate::new()), + StateUpdate::Idle(IdleStateUpdate::new().stop_reason(StopReason::Cancelled)), + ] { + let child_notification = UpdateSessionNotification::new( + association.session_id.clone(), + SessionUpdate::StateUpdate(state.clone()), + ); + let parent_notification = UpdateSessionNotification::new( + "sess_parent", + SessionUpdate::SubagentUpdate(association.clone().state(state)), + ); + let child_wire = serde_json::to_value(&child_notification).unwrap(); + let parent_wire = serde_json::to_value(&parent_notification).unwrap(); + assert_eq!(child_wire["sessionId"], json!("sess_child")); + assert_eq!(child_wire["update"]["sessionUpdate"], json!("state_update")); + assert_eq!(parent_wire["sessionId"], json!("sess_parent")); + assert_eq!(parent_wire["update"]["sessionId"], json!("sess_child")); + let mut child_snapshot = child_wire["update"].clone(); + child_snapshot + .as_object_mut() + .unwrap() + .remove("sessionUpdate"); + assert_eq!(parent_wire["update"]["state"], child_snapshot); + assert_eq!( + serde_json::from_value::(child_wire).unwrap(), + child_notification + ); + assert_eq!( + serde_json::from_value::(parent_wire).unwrap(), + parent_notification + ); + } + } + + #[cfg(feature = "unstable_subagents")] + #[test] + fn subagent_state_uses_whole_snapshot_patch_semantics() { + use serde_json::json; + + let minimal = SubagentUpdate::new("child"); + assert!(minimal.state.is_undefined()); + assert_eq!( + serde_json::to_value(&minimal).unwrap(), + json!({"sessionId": "child"}) + ); + + let cleared = minimal.clone().state(None::); + assert!(cleared.state.is_null()); + assert_eq!( + serde_json::to_value(&cleared).unwrap(), + json!({"sessionId": "child", "state": null}) + ); + assert_eq!( + serde_json::from_value::(json!({"sessionId": "child", "state": null})) + .unwrap(), + cleared + ); + + for snapshot in [ + json!({"state": "running", "_meta": {"source": "worker"}}), + json!({"state": "idle", "stopReason": "end_turn"}), + json!({"state": "requires_action"}), + json!({"state": "unknown"}), + json!({"state": "_custom", "detail": {"phase": 2}}), + ] { + let state: StateUpdate = serde_json::from_value(snapshot.clone()).unwrap(); + let update = minimal.clone().state(state.clone()); + let wire = json!({"sessionId": "child", "state": snapshot}); + assert_eq!(serde_json::to_value(&update).unwrap(), wire); + assert_eq!( + serde_json::from_value::(wire).unwrap(), + update + ); + assert_eq!(update.state, MaybeUndefined::Value(state)); + } + + // Each concrete value is the entire replacement snapshot, not a merge + // with a previous state's stop reason or metadata. + let idle = SubagentUpdate::new("child").state(StateUpdate::Idle( + IdleStateUpdate::new().stop_reason(StopReason::Cancelled), + )); + let running = idle.state(StateUpdate::Running(RunningStateUpdate::new())); + assert_eq!( + serde_json::to_value(running).unwrap(), + json!({"sessionId": "child", "state": {"state": "running"}}) + ); + for invalid in [json!(false), json!(42), json!({}), json!({"state": 7})] { + let parsed: SubagentUpdate = + serde_json::from_value(json!({"sessionId": "child", "state": invalid})).unwrap(); + assert_eq!(parsed, minimal); + } + } + + #[cfg(feature = "unstable_subagents")] + #[test] + fn unknown_activity_is_a_known_state_update() { + use serde_json::json; + + let update = SessionUpdate::StateUpdate(StateUpdate::Unknown( + UnknownStateUpdate::new().meta( + [("source".into(), json!("worker"))] + .into_iter() + .collect::(), + ), + )); + let wire = json!({ + "sessionUpdate": "state_update", + "state": "unknown", + "_meta": { "source": "worker" } + }); + assert_eq!(serde_json::to_value(&update).unwrap(), wire); + assert_eq!( + serde_json::from_value::(wire).unwrap(), + update + ); + + for meta in [json!(null), json!(false)] { + let state: StateUpdate = serde_json::from_value(json!({ + "state": "unknown", + "_meta": meta + })) + .unwrap(); + assert_eq!(state, StateUpdate::Unknown(UnknownStateUpdate::new())); + assert_eq!( + serde_json::to_value(state).unwrap(), + json!({"state": "unknown"}) + ); + } + assert!(serde_json::from_value::(json!({"state": "unknown"})).is_err()); + } + + #[cfg(not(feature = "unstable_subagents"))] + #[test] + fn unknown_activity_is_preserved_without_subagents_feature() { + let wire = serde_json::json!({ + "sessionUpdate": "state_update", + "state": "unknown", + "_meta": { "source": "worker" } + }); + let parsed: SessionUpdate = serde_json::from_value(wire.clone()).unwrap(); + let SessionUpdate::StateUpdate(StateUpdate::Other(state)) = &parsed else { + panic!("expected unrecognized state payload"); + }; + assert_eq!(state.state, "unknown"); + assert_eq!(serde_json::to_value(parsed).unwrap(), wire); + } + + #[cfg(all(feature = "unstable_subagents", feature = "schemars"))] + #[test] + fn subagent_schema_carries_optional_nullable_state_snapshot() { + use serde_json::json; + + let schema = serde_json::to_value(schemars::schema_for!(SubagentUpdate)).unwrap(); + let properties = schema["properties"].as_object().unwrap(); + assert!(properties.contains_key("sessionId")); + assert!(properties.contains_key("title")); + assert!(properties.contains_key("description")); + assert!(properties.contains_key("capabilities")); + assert!(properties.contains_key("state")); + assert!(properties.contains_key("_meta")); + assert!(!properties.contains_key("name")); + assert!(!properties.contains_key("task")); + assert_eq!(schema["required"], json!(["sessionId"])); + assert_eq!( + properties["state"]["x-deserialize-default-on-error"], + json!(true) + ); + let state_variants = properties["state"]["anyOf"].as_array().unwrap(); + assert!(state_variants.contains(&json!({"type": "null"}))); + assert!( + state_variants + .iter() + .any(|variant| variant["$ref"] == "#/$defs/StateUpdate") + ); + let child = + serde_json::to_value(schemars::schema_for!(SubagentSessionCapabilities)).unwrap(); + let variants = child["properties"]["cancel"]["anyOf"] + .as_array() + .expect("cancel must allow the capability object or null"); + assert!(variants.contains(&json!({"type": "null"}))); + assert!( + variants + .iter() + .any(|variant| variant["type"] == "object" || variant.get("$ref").is_some()) + ); + assert!(!variants.iter().any(|variant| variant["type"] == "boolean")); + let cancel = + serde_json::to_value(schemars::schema_for!(SessionCancelCapabilities)).unwrap(); + assert_eq!(cancel["type"], "object"); + assert!( + !child["required"] + .as_array() + .is_some_and(|required| required.contains(&json!("cancel"))) + ); + } + + #[cfg(not(feature = "unstable_subagents"))] + #[test] + fn unsupported_subagent_update_is_preserved() { + let wire = serde_json::json!({ + "sessionUpdate": "subagent_update", + "sessionId": "sess_child", + "capabilities": { "cancel": {} } + }); + let parsed: SessionUpdate = serde_json::from_value(wire.clone()).unwrap(); + assert!(matches!(&parsed, SessionUpdate::Other(_))); + assert_eq!(serde_json::to_value(parsed).unwrap(), wire); + } + #[cfg(feature = "unstable_session_notices")] #[test] fn notice_preserves_wire_shape_nullable_fields_and_open_severity() { diff --git a/docs/docs.json b/docs/docs.json index 22195bdc1..3fb642132 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -194,7 +194,8 @@ "rfds/custom-llm-endpoint", "rfds/plan-operations", "rfds/end-turn-token-usage", - "rfds/get-auth-state" + "rfds/get-auth-state", + "rfds/subagents" ] }, { diff --git a/docs/protocol/v1/draft/prompt-turn.mdx b/docs/protocol/v1/draft/prompt-turn.mdx index 30f54daa9..a873fe7c7 100644 --- a/docs/protocol/v1/draft/prompt-turn.mdx +++ b/docs/protocol/v1/draft/prompt-turn.mdx @@ -11,29 +11,78 @@ Before sending prompts, Clients **MUST** first complete the [initialization](/pr Agents send `session/update` notifications to report streamed content, tool activity, and changes to session state. The `sessionUpdate` field inside `params.update` identifies the variant: -| `sessionUpdate` value | Description | -| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `user_message_chunk` | A streamed chunk of a user message. | -| `agent_message_chunk` | A streamed chunk of the Agent's response. | -| `agent_thought_chunk` | A streamed chunk of the Agent's reasoning. | -| `tool_call` | A new [tool call](/protocol/v1/draft/tool-calls#creating). | -| `tool_call_update` | A change to a [tool call's status or results](/protocol/v1/draft/tool-calls#updating). | -| `plan` | The Agent's [execution plan](/protocol/v1/draft/agent-plan). | -| `plan_update` | **Draft only.** A [content update for a plan](/protocol/v1/draft/agent-plan#plan-operations) identified by ID. Requires the Client's `plan` capability. | -| `plan_removed` | **Draft only.** [Removal of a plan](/protocol/v1/draft/agent-plan#removing-plans) identified by ID. Requires the Client's `plan` capability. | -| `available_commands_update` | The set of [available slash commands](/protocol/v1/draft/slash-commands) is ready or has changed. | -| `current_mode_update` | The current [session mode](/protocol/v1/draft/session-modes#from-the-agent) has changed. | -| `config_option_update` | The session's [configuration options](/protocol/v1/draft/session-config-options#from-the-agent) have changed. | -| `session_info_update` | [Session metadata](/protocol/v1/draft/session-list#updating-session-metadata), such as the title or last update time, has changed. | -| `usage_update` | The session's [context window usage and cumulative cost](#session-usage-updates). | -| `notice` | **Draft only.** A live [advisory notice](#session-notices) that is not part of session history. Requires the Client's `session.notices` capability. | -| `compaction_update` | **Draft only.** Creates or updates a [context compaction](#session-compaction). Requires the Client's `session.compaction` capability. | -| `compaction_summary_chunk` | **Draft only.** Appends content to a [compaction's retained summary](#session-compaction). Requires the Client's `session.compaction` capability. | +| `sessionUpdate` value | Description | +| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `user_message_chunk` | A streamed chunk of a user message. | +| `agent_message_chunk` | A streamed chunk of the Agent's response. | +| `agent_thought_chunk` | A streamed chunk of the Agent's reasoning. | +| `subagent_update` | **Draft only.** Announces an owned child session or updates its parent-owned metadata and work state. Requires the Client's `subagents` capability. | +| `session_message` | **Draft only.** Creates or updates a [message addressed to another session](#session-directed-messages-unstable). Requires the Client's `subagents` capability. | +| `session_message_chunk` | **Draft only.** Appends content to a [session-directed message](#session-directed-messages-unstable). Requires the Client's `subagents` capability. | +| `tool_call` | A new [tool call](/protocol/v1/draft/tool-calls#creating). | +| `tool_call_update` | A change to a [tool call's status or results](/protocol/v1/draft/tool-calls#updating). | +| `plan` | The Agent's [execution plan](/protocol/v1/draft/agent-plan). | +| `plan_update` | **Draft only.** A [content update for a plan](/protocol/v1/draft/agent-plan#plan-operations) identified by ID. Requires the Client's `plan` capability. | +| `plan_removed` | **Draft only.** [Removal of a plan](/protocol/v1/draft/agent-plan#removing-plans) identified by ID. Requires the Client's `plan` capability. | +| `available_commands_update` | The set of [available slash commands](/protocol/v1/draft/slash-commands) is ready or has changed. | +| `current_mode_update` | The current [session mode](/protocol/v1/draft/session-modes#from-the-agent) has changed. | +| `config_option_update` | The session's [configuration options](/protocol/v1/draft/session-config-options#from-the-agent) have changed. | +| `session_info_update` | [Session metadata](/protocol/v1/draft/session-list#updating-session-metadata), such as the title or last update time, has changed. | +| `usage_update` | The session's [context window usage and cumulative cost](#session-usage-updates). | +| `notice` | **Draft only.** A live [advisory notice](#session-notices) that is not part of session history. Requires the Client's `session.notices` capability. | +| `compaction_update` | **Draft only.** Creates or updates a [context compaction](#session-compaction). Requires the Client's `session.compaction` capability. | +| `compaction_summary_chunk` | **Draft only.** Appends content to a [compaction's retained summary](#session-compaction). Requires the Client's `session.compaction` capability. | This is the complete set of variants defined in the current draft [`SessionUpdate` schema](/protocol/v1/draft/schema#sessionupdate), which describes each payload in detail. Draft-only variants are unstable and may change or be removed. The walkthrough below illustrates common uses of these updates. Session updates are not limited to active prompt turns. For example, Agents may advertise commands after creating a session, and replay user and Agent messages when [loading a session](/protocol/v1/draft/session-setup#loading-sessions). +### Session-directed messages (unstable) + +When the Client advertises `subagents: {}`, the Agent may report outgoing and +incoming messages between sessions, separately from user-facing responses and +tool calls. The outer `params.sessionId` identifies the transcript being +updated. Every message and chunk requires a `messageId` scoped to that +transcript. Optional `senderSessionId` and `recipientSessionId` metadata +identify the participants. Include them on the first update when available; +later updates may omit them or add missing identities. + +For example, an incoming message in the child can begin with a streamed chunk: + +```json +{ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "child_session", + "update": { + "sessionUpdate": "session_message_chunk", + "messageId": "received_message_1", + "senderSessionId": "parent_session", + "recipientSessionId": "child_session", + "content": { "type": "text", "text": "Please investigate " } + } + } +} +``` + +Every chunk carries one normal `ContentBlock`; later chunks append content +for the same `messageId`. `session_message` can supply a whole +`content` array to replace accumulated content. Its optional `content` and +`_meta` fields leave prior values unchanged when omitted; `null` clears them. +`content: []` also clears content. Chunk metadata is local to the chunk, not a +message-level patch. + +Clients can render "To …" with a recipient link or "From …" with a sender link. +Without sufficient participant metadata, use a generic inter-session +presentation and add links when the metadata arrives. Omitted or `null` +participant IDs mean not supplied and leave previously known identities intact. +The two views have independent message IDs. The Agent reports each side only +when observed; Clients must not synthesize an incoming entry from an outgoing +one or forward the content themselves. Neither view acknowledges completed +recipient processing. See the [Subagent Sessions RFD](/rfds/subagents#session-directed-messages) +for participant registration, history gaps, and ownership rules. + ## The Prompt Turn Lifecycle A prompt turn follows a structured flow that enables rich interactions between the user, Agent, and any connected tools. diff --git a/docs/protocol/v1/draft/schema.mdx b/docs/protocol/v1/draft/schema.mdx index 007b66cd7..822e0f553 100644 --- a/docs/protocol/v1/draft/schema.mdx +++ b/docs/protocol/v1/draft/schema.mdx @@ -3327,6 +3327,19 @@ The position encodings supported by the client, in order of preference. Optional. Omitted or `null` both mean the client does not advertise any session-related extensions. + +SubagentCapabilities | null} > + **UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +Whether the client understands exposed subagent sessions. + +Optional and nullable. Omitted or `null` both mean the client does not +advertise support. +Supplying `\{\}` means the client understands child associations, work-state +snapshots, session-directed messages, and restricted-session semantics. + Whether the Client support all `terminal/*` methods. @@ -4812,6 +4825,29 @@ See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v1/d The value to set for the HTTP header. +## IdleStateUpdate + +The child is ready to process another prompt. + +**Type:** Object + +**Properties:** + + + The _meta property is reserved by ACP for additional metadata. +Implementations MUST NOT make assumptions about values at these keys. +Optional; omitted and `null` mean no metadata for this state snapshot. + +StopReason | null} > + Reason foreground work stopped. Optional; omitted or `null` means not reported. + +Usage | null} > + **UNSTABLE** Token usage for completed foreground work. + +Optional; omitted or `null` means not reported. + + + ## ImageContent An image provided to or from an LLM. @@ -7253,6 +7289,20 @@ See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v1/d +## RequiresActionStateUpdate + +Foreground work is blocked on user action. + +**Type:** Object + +**Properties:** + + + The _meta property is reserved by ACP for additional metadata. Implementations + MUST NOT make assumptions about values at these keys. Optional; omitted and + `null` mean no metadata for this state snapshot. + + ## ResourceLink A resource that the server is capable of reading, included in a prompt or tool call result. @@ -7305,6 +7355,20 @@ The sender or recipient of messages and data in a conversation. The user side of a conversation. +## RunningStateUpdate + +Foreground work is in progress. + +**Type:** Object + +**Properties:** + + + The _meta property is reserved by ACP for additional metadata. Implementations + MUST NOT make assumptions about values at these keys. Optional; omitted and + `null` mean no metadata for this state snapshot. + + ## SelectedPermissionOutcome The user selected one of the provided options. @@ -7347,6 +7411,29 @@ See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v1/d +## SessionCancelCapabilities + +**UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +Capability to cancel work in a subagent session without ending that session. + +Supplying `\{\}` advertises support; an omitted or `null` `cancel` does not. + +**Type:** Object + +**Properties:** + + + The _meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v1/draft/extensibility) + + + ## SessionCapabilities Session capabilities supported by the agent. @@ -7828,6 +7915,90 @@ See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v1/d +## SessionMessage + +**UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +An upsert of an inter-session message in the enclosing transcript. +`messageId` is local to that transcript; separate views may use independent +IDs. Endpoints are optional identity metadata, not content patches: omitted +or `null` retains a known endpoint. Agents SHOULD supply available endpoints +on the first event; later events may omit them or enrich missing endpoints. +Supplied endpoints must agree with the enclosing transcript. Live IDs refer +to known sessions; history may retain unavailable counterparts. Missing +identities permit generic inter-session UI, not guessed participants or +human authorship. +Omitted `content` and `_meta` leave their stored values unchanged; `null` +clears them. A concrete `content` array replaces existing content +(`[]` also clears it); later chunks append. This neither changes session +ownership nor instructs the Client to deliver content. + +**Type:** Object + +**Properties:** + + + Omitted leaves metadata unchanged; `null` removes it. + +Implementations MUST NOT make assumptions about values in `_meta`. + + +ContentBlock[] | null} > + Omitted leaves content unchanged; `null` or `[]` clears it. +A non-empty array replaces all content. + +MessageId} required> + Identifier of this message within the enclosing session's transcript. + +SessionId | null} > + Optional receiving session identity; omission or `null` retains a known value. + +SessionId | null} > + Optional sending session identity; omission or `null` retains a known value. + + +## SessionMessageChunk + +**UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +A content block appended to a transcript-local message in arrival order. +Endpoints are optional identity metadata, not content patches: omitted or +`null` does not clear a known endpoint. Agents SHOULD supply available +endpoints on the first event; later events may omit them or enrich missing +endpoints. Supplied endpoints must agree with the enclosing transcript. +Live IDs refer to known sessions; history may retain unavailable counterparts. +Missing identities permit generic inter-session UI, not guessed participants +or human authorship. +Chunk metadata applies only to that chunk. This does not instruct the Client +to deliver content or imply that the recipient processed it. + +**Type:** Object + +**Properties:** + + + Optional and nullable chunk-scoped metadata; omitted or `null` means none. + +Implementations MUST NOT make assumptions about values in `_meta`. + + +ContentBlock} required> + A single content block appended to the message. + +MessageId} required> + Identifier of this message within the enclosing session's transcript. + +SessionId | null} > + Optional receiving session identity; omission or `null` retains a known value. + +SessionId | null} > + Optional sending session identity; omission or `null` retains a known value. + + ## SessionMode A mode the agent can operate in. @@ -8450,6 +8621,250 @@ Agents MUST only send this update when the Client advertised + +**UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +Announces a child session created and owned by this session, or updates +that ownership association's metadata. + + + + + The _meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v1/draft/extensibility) +Omitted means unchanged; `null` removes the metadata. + + +SubagentSessionCapabilities | null} > + Client-initiated session mutations permitted for this subagent session. + +Omitted means unchanged; `null` clears the capability set and disables +child mutations. If never supplied, no session mutations are permitted. +Read-only operations retain their normal protocol semantics and capability +requirements. A concrete object replaces the whole capability set. + + + + The parent's human-readable description of the child's role or purpose. + +Omitted means unchanged; `null` clears it. If unset, the Client chooses +a fallback presentation. This is current display metadata, not the +history of instructions sent to the child. + + +SessionId} required> + The opaque session ID identifying the child in all ACP messages. + +Nested inside `update`; the enclosing notification's `sessionId` identifies +the immediate parent, not this child. + + + + The discriminator value. Must be `"subagent_update"`. + +StateUpdate | null} > + Current state snapshot for the child session. + +Omitted means unchanged; `null` clears the current activity without +asserting idle or sending an `unknown` snapshot. A concrete state +replaces the previous state object wholesale. If never supplied, the +current activity is unset/unconfirmed. + + + + The parent's human-readable display title for this child. It need not be unique. + +Omitted means unchanged; `null` clears it. If unset, the Client chooses +a fallback presentation. + + + + + + + +**UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +A message upsert observed in this session's transcript, sent to or +received from another session. + + + + + Omitted leaves metadata unchanged; `null` removes it. + +Implementations MUST NOT make assumptions about values in `_meta`. + + +ContentBlock[] | null} > + Omitted leaves content unchanged; `null` or `[]` clears it. +A non-empty array replaces all content. + +MessageId} required> + Identifier of this message within the enclosing session's transcript. + +SessionId | null} > + Optional receiving session identity; omission or `null` retains a known value. + +SessionId | null} > + Optional sending session identity; omission or `null` retains a known value. + + + The discriminator value. Must be `"session_message"`. + + + + + + +**UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +One content block appended to a sent or received session message. + + + + + Optional and nullable chunk-scoped metadata; omitted or `null` means none. + +Implementations MUST NOT make assumptions about values in `_meta`. + + +ContentBlock} required> + A single content block appended to the message. + +MessageId} required> + Identifier of this message within the enclosing session's transcript. + +SessionId | null} > + Optional receiving session identity; omission or `null` retains a known value. + +SessionId | null} > + Optional sending session identity; omission or `null` retains a known value. + + + The discriminator value. Must be `"session_message_chunk"`. + + + + + +## StateUpdate + +**UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +Current foreground-work state of a reusable child session. + +Each update is a whole-object snapshot. Idle does not terminate the child; +the parent can message it again, transitioning it back to running. +Background activity may still emit other session updates while idle. + +**Type:** Union + + +Foreground work is in progress. + + + + + The _meta property is reserved by ACP for additional metadata. Implementations + MUST NOT make assumptions about values at these keys. Optional; omitted and + `null` mean no metadata for this state snapshot. + + + The discriminator value. Must be `"running"`. + + + + + + +The child is ready to process another prompt. + + + + + The _meta property is reserved by ACP for additional metadata. +Implementations MUST NOT make assumptions about values at these keys. +Optional; omitted and `null` mean no metadata for this state snapshot. + + + The discriminator value. Must be `"idle"`. + +StopReason | null} > + Reason foreground work stopped. Optional; omitted or `null` means not reported. + +Usage | null} > + **UNSTABLE** Token usage for completed foreground work. + +Optional; omitted or `null` means not reported. + + + + + + + +Foreground work is blocked on user action. + + + + + The _meta property is reserved by ACP for additional metadata. Implementations + MUST NOT make assumptions about values at these keys. Optional; omitted and + `null` mean no metadata for this state snapshot. + + + The discriminator value. Must be `"requires_action"`. + + + + + + +The Agent cannot currently determine foreground activity. + +This replaces previously confirmed activity without ending the work or session. + + + + + The _meta property is reserved by ACP for additional metadata. Implementations + MUST NOT make assumptions about values at these keys. Optional; omitted and + `null` mean no metadata for this state snapshot. + + + The discriminator value. Must be `"unknown"`. + + + + + + +Custom or future state. + +Values beginning with `_` are reserved for implementation-specific +extensions. Other unknown values are reserved for future ACP variants. + + + + + Unrecognized state discriminator. + + + + + ## StopReason Reasons why an agent stops processing a prompt turn. @@ -8609,6 +9024,135 @@ Optional. Omitted and `null` are equivalent and mean no title is provided. +## SubagentCapabilities + +**UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +Capability marker for exposing reusable child sessions as restricted ACP sessions. + +Supplying `\{\}` advertises support for child association and state updates, +session-directed messages, and restricted-session semantics. The client +must advertise this capability before the agent sends subagent updates or +session-directed messages. + +**Type:** Object + +**Properties:** + + + The _meta property is reserved by ACP to allow clients and agents to attach + additional metadata to their interactions. Implementations MUST NOT make + assumptions about values at these keys. + + +## SubagentSessionCapabilities + +**UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +Client-initiated session mutations permitted for a specific subagent session. + +A mutation requires an explicit per-child capability; support for the method +on ordinary sessions does not grant support on a child. + +**Type:** Object + +**Properties:** + + + The _meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +SessionCancelCapabilities | null} > + Permits the client to cancel this child's current work without ending +the session. Omitted or `null` means unsupported; an object (including +`\{\}`) means supported. + + +## SubagentUpdate + +**UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +Notification that the enclosing parent session created and owns a child session. + +Later updates modify the existing association's metadata, not its ownership. + +Sent on the immediate parent session. The first update for an unknown +`SubagentUpdate::session_id` announces the child and MUST be sent +before any live child traffic or live message naming the child as sender +or recipient. Parents may message and reuse an announced child across +multiple operations. +Child events are delivered automatically on the same connection; no child +load, resume, or subscription is needed. + +Only the subagent session ID is required. Omitted patch fields keep their +previous values; `null` clears them. Clearing capabilities disables child +mutations. Clearing state leaves current activity unset/unconfirmed: it does +not imply idle, stop work, or create an `unknown` state snapshot. A concrete +state replaces the entire previous state object, not the session. +The title and description provide the parent's display metadata for the +child. They do not replace the content of individual messages or operations. + +**Type:** Object + +**Properties:** + + + The _meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v1/draft/extensibility) +Omitted means unchanged; `null` removes the metadata. + + +SubagentSessionCapabilities | null} > + Client-initiated session mutations permitted for this subagent session. + +Omitted means unchanged; `null` clears the capability set and disables +child mutations. If never supplied, no session mutations are permitted. +Read-only operations retain their normal protocol semantics and capability +requirements. A concrete object replaces the whole capability set. + + + + The parent's human-readable description of the child's role or purpose. + +Omitted means unchanged; `null` clears it. If unset, the Client chooses +a fallback presentation. This is current display metadata, not the +history of instructions sent to the child. + + +SessionId} required> + The opaque session ID identifying the child in all ACP messages. + +Nested inside `update`; the enclosing notification's `sessionId` identifies +the immediate parent, not this child. + + +StateUpdate | null} > + Current state snapshot for the child session. + +Omitted means unchanged; `null` clears the current activity without +asserting idle or sending an `unknown` snapshot. A concrete state +replaces the previous state object wholesale. If never supplied, the +current activity is unset/unconfirmed. + + + + The parent's human-readable display title for this child. It need not be unique. + +Omitted means unchanged; `null` clears it. If unset, the Client chooses +a fallback presentation. + + + ## Terminal Embed a terminal created with `terminal/create` by its id. @@ -9089,6 +9633,32 @@ See protocol docs: [Creating](https://agentclientprotocol.com/protocol/v1/draft/ Other tool types (default). +## UnknownStateUpdate + +**UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +The Agent cannot currently determine foreground activity. + +Report this when activity becomes unobservable, not merely because the child +has been quiet. The Client MUST stop presenting the previous state as confirmed +current activity, but may retain it as last known. A later state replaces this +snapshot normally. + +This is not a task outcome or session closure. It does not cancel work, resolve +pending requests, or revoke capabilities; capabilities are updated separately. + +**Type:** Object + +**Properties:** + + + The _meta property is reserved by ACP for additional metadata. Implementations + MUST NOT make assumptions about values at these keys. Optional; omitted and + `null` mean no metadata for this state snapshot. + + ## UnstructuredCommandInput All text that was typed after the command name is provided as input. diff --git a/docs/protocol/v2/draft/prompt-lifecycle.mdx b/docs/protocol/v2/draft/prompt-lifecycle.mdx index 0bf6846c2..5b036eb02 100644 --- a/docs/protocol/v2/draft/prompt-lifecycle.mdx +++ b/docs/protocol/v2/draft/prompt-lifecycle.mdx @@ -21,6 +21,9 @@ Agents send `session/update` notifications to report streamed content, tool acti | `agent_message` | Creates or updates an Agent message identified by `messageId`. | | `agent_thought_chunk` | A streamed chunk of the Agent's reasoning. | | `agent_thought` | Creates or updates an Agent reasoning message identified by `messageId`. | +| `subagent_update` | **Draft only.** Announces an owned child session or updates its display metadata, capabilities, and mirrored work-state snapshot. | +| `session_message` | **Draft only.** Creates or updates a [message addressed to another session](#session-directed-messages-unstable). | +| `session_message_chunk` | **Draft only.** Appends content to a [session-directed message](#session-directed-messages-unstable). | | `state_update` | A change to the Agent's [foreground work state](#session-states). | | `tool_call_content_chunk` | Appends a [content chunk to a tool call](/protocol/v2/draft/tool-calls#streaming-content). | | `tool_call_update` | Creates or updates a [tool call](/protocol/v2/draft/tool-calls#reporting). | @@ -42,6 +45,51 @@ The protocol also supports [custom and future variants](/protocol/v2/draft/exten Session updates are not limited to prompt-driven foreground work. For example, Agents may advertise commands after creating a session, and background activity may emit updates while the Agent is [idle](#session-states). +### Session-directed messages (unstable) + +The Agent may report outgoing and incoming messages between sessions, +separately from user-facing responses and tool calls. The outer +`params.sessionId` identifies the transcript being updated. Every +`session_message` or `session_message_chunk` requires `messageId`, scoped to +that transcript. Optional `senderSessionId` and `recipientSessionId` metadata +identify the participants. Include them on the first update when available; +later updates may omit them or add missing identities. + +For example, an incoming message in the child can begin with a streamed chunk: + +```json +{ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "child_session", + "update": { + "sessionUpdate": "session_message_chunk", + "messageId": "received_message_1", + "senderSessionId": "parent_session", + "recipientSessionId": "child_session", + "content": { "type": "text", "text": "Please investigate " } + } + } +} +``` + +Chunks append normal message content for the same IDs. A `session_message` +upsert may replace the whole `content` array or update message metadata. +Omission leaves either field unchanged; `null` clears it. `content: []` also +clears accumulated content. Chunk `_meta` is scoped to that chunk and does not +patch message metadata. + +Clients can render "To …" with a recipient link or "From …" with a sender link. +Without sufficient participant metadata, use a generic inter-session +presentation and add links when the metadata arrives. Omitted or `null` +participant IDs mean not supplied and leave previously known identities intact. +The two views have independent message IDs. The Agent reports each side only +when observed; Clients must not synthesize an incoming entry from an outgoing +one or forward the content themselves. Neither view acknowledges completed +recipient processing. See the [Subagent Sessions RFD](/rfds/subagents#session-directed-messages) +for participant registration, history gaps, and ownership rules. + ## Prompt Lifecycle A typical prompt-driven flow enables rich interactions between the user, Agent, and any connected tools. @@ -598,6 +646,11 @@ Custom or future stop reasons can be used when Clients can display a generic sto `state_update` reports foreground work. +For exposed subagents, Agents **SHOULD** also mirror the same snapshot in the +immediate parent's `subagent_update.state`. This keeps the parent's roster and +the child's own stream informed about one logical state; duplicate snapshots +do not represent additional work or usage. See [current child work state](/rfds/subagents#current-work-state). + Foreground work is in progress. @@ -608,6 +661,23 @@ Custom or future stop reasons can be used when Clients can display a generic sto Foreground work is blocked on user action. + + The Agent cannot currently determine foreground activity. This experimental + state is introduced by the subagents RFD and gated by `unstable_subagents` in + the Rust schema. For example, a remote child feed may be lost while ACP + remains connected. + + +Agents report `unknown` on actual loss of observability, not merely after +silence. Clients must no longer present a prior `running` or `requires_action` +as confirmed live activity, though they may retain the last-known state for +context. A subsequent `running`, `requires_action`, or `idle` replaces +`unknown` for the same session. `unknown` is neither a work outcome nor a +closure or cancellation, and does not resolve pending requests or automatically +revoke child mutation capabilities; the Agent updates those capabilities +separately if a control becomes unavailable. See the experimental +[subagents RFD](/rfds/subagents) for v1 and recovery behavior. + Background activity **MAY** continue and emit other `session/update` notifications while the Agent reports `idle`. These notifications do not change the state. ## Cancellation diff --git a/docs/protocol/v2/draft/schema.mdx b/docs/protocol/v2/draft/schema.mdx index 22a21b0f2..0351f1804 100644 --- a/docs/protocol/v2/draft/schema.mdx +++ b/docs/protocol/v2/draft/schema.mdx @@ -7739,6 +7739,29 @@ See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/d +## SessionCancelCapabilities + +**UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +Capability to cancel work in a subagent session without ending that session. + +Supplying `\{\}` advertises support; an omitted or `null` `cancel` does not. + +**Type:** Object + +**Properties:** + + + The _meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/draft/extensibility) + + + ## SessionCapabilities Session capabilities supported by the agent. @@ -8196,6 +8219,88 @@ An opaque cursor used to paginate `session/list` results. **Type:** `string` +## SessionMessage + +**UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +An upsert of an inter-session message in the enclosing transcript. +`messageId` is local to that transcript; separate views may use independent +IDs. Endpoints are optional identity metadata, not content patches: omitted +or `null` retains a known endpoint. Agents SHOULD supply available endpoints +on the first event; later events may omit them or enrich missing endpoints. +Supplied endpoints must agree with the enclosing transcript. Live IDs refer +to known sessions; history may retain unavailable counterparts. Missing +identities permit generic inter-session UI, not guessed participants or +human authorship. +A concrete `content` array replaces existing content; later chunks append. +This neither changes session ownership nor instructs the Client to deliver content. + +**Type:** Object + +**Properties:** + + + Omitted leaves metadata unchanged; `null` clears it. + +Implementations MUST NOT make assumptions about values in `_meta`. + + +ContentBlock[] | null} > + Omitted leaves content unchanged; `null` clears it; a concrete array +replaces the whole content collection. + +MessageId} required> + Identifier of this message within the enclosing session's transcript. + +SessionId | null} > + Optional receiving session identity; omission or `null` retains a known value. + +SessionId | null} > + Optional sending session identity; omission or `null` retains a known value. + + +## SessionMessageChunk + +**UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +A content block appended to a transcript-local message in arrival order. +Endpoints are optional identity metadata, not content patches: omitted or +`null` does not clear a known endpoint. Agents SHOULD supply available +endpoints on the first event; later events may omit them or enrich missing +endpoints. Supplied endpoints must agree with the enclosing transcript. +Live IDs refer to known sessions; history may retain unavailable counterparts. +Missing identities permit generic inter-session UI, not guessed participants +or human authorship. +Chunk metadata applies only to that chunk. This does not instruct the Client +to deliver content or imply that the recipient processed it. + +**Type:** Object + +**Properties:** + + + Optional and nullable chunk-scoped metadata; omitted or `null` means none. + +Implementations MUST NOT make assumptions about values in `_meta`. + + +ContentBlock} required> + A single content block appended to the message. + +MessageId} required> + Identifier of this message within the enclosing session's transcript. + +SessionId | null} > + Optional receiving session identity; omission or `null` retains a known value. + +SessionId | null} > + Optional sending session identity; omission or `null` retains a known value. + + ## SessionUpdate Different types of updates that can be sent while a session exists. @@ -8477,6 +8582,28 @@ See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/d + +**UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +The Agent cannot currently determine foreground activity. +This replaces previously confirmed activity without ending the work or session. + + + + + The _meta property is reserved by ACP for additional metadata. Implementations + MUST NOT make assumptions about values at these keys. Optional; omitted and + `null` mean no metadata for this state snapshot. + + + The discriminator value. Must be `"unknown"`. + + + + + Custom or future session state. @@ -8929,6 +9056,137 @@ A content block appended to a context compaction's retained summary. + +**UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +Announces a child session created and owned by this session, or updates +that ownership association's metadata. + + + + + The _meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Omitted means no metadata update; `null` is an +explicit clear signal. Implementations MUST NOT make assumptions about values at these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/draft/extensibility) + + +SubagentSessionCapabilities | null} > + Client-initiated session mutations permitted for this subagent session. + +Read-only operations retain their normal protocol semantics and +capability requirements. + + + + The parent's human-readable description of the child's role or purpose. + +Optional and nullable. Omitted means unchanged; `null` clears it. This is +current display metadata, not the history of instructions sent to the child. + + +SessionId} required> + The opaque session ID identifying the child in all ACP messages. + +Nested inside `update`; the enclosing notification's `sessionId` identifies +the immediate parent, not this child. + + + + The discriminator value. Must be `"subagent_update"`. + +StateUpdate | null} > + The child's current foreground state, mirrored onto its parent association. + +Optional and nullable. Omitted means unchanged; `null` removes the +current report and leaves activity unconfirmed (not idle or cancelled). +A concrete `StateUpdate` replaces the entire previous snapshot. + + + + The parent's human-readable display title for this child. It need not be unique. + +Optional and nullable. Omitted means unchanged; `null` clears it. If no +title is set, the Client chooses a fallback presentation. + + + + + + + +**UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +A message upsert observed in this session's transcript, sent to or +received from another session. + + + + + Omitted leaves metadata unchanged; `null` clears it. + +Implementations MUST NOT make assumptions about values in `_meta`. + + +ContentBlock[] | null} > + Omitted leaves content unchanged; `null` clears it; a concrete array +replaces the whole content collection. + +MessageId} required> + Identifier of this message within the enclosing session's transcript. + +SessionId | null} > + Optional receiving session identity; omission or `null` retains a known value. + +SessionId | null} > + Optional sending session identity; omission or `null` retains a known value. + + + The discriminator value. Must be `"session_message"`. + + + + + + +**UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +One content block appended to a sent or received session message. + + + + + Optional and nullable chunk-scoped metadata; omitted or `null` means none. + +Implementations MUST NOT make assumptions about values in `_meta`. + + +ContentBlock} required> + A single content block appended to the message. + +MessageId} required> + Identifier of this message within the enclosing session's transcript. + +SessionId | null} > + Optional receiving session identity; omission or `null` retains a known value. + +SessionId | null} > + Optional sending session identity; omission or `null` retains a known value. + + + The discriminator value. Must be `"session_message_chunk"`. + + + + + Custom or future session update. @@ -9041,6 +9299,28 @@ See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/d + +**UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +The Agent cannot currently determine foreground activity. +This replaces previously confirmed activity without ending the work or session. + + + + + The _meta property is reserved by ACP for additional metadata. Implementations + MUST NOT make assumptions about values at these keys. Optional; omitted and + `null` mean no metadata for this state snapshot. + + + The discriminator value. Must be `"unknown"`. + + + + + Custom or future session state. @@ -9241,6 +9521,117 @@ Optional. Omitted and `null` are equivalent and mean no title is provided. +## SubagentSessionCapabilities + +**UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +Client-initiated session mutations permitted for a specific subagent session. + +A mutation requires an explicit per-child capability; support for the method +on ordinary sessions does not grant support on a child. + +**Type:** Object + +**Properties:** + + + The _meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +SessionCancelCapabilities | null} > + Permits the client to cancel this child's current work without ending +the session. Omitted or `null` means unsupported; an object (including +`\{\}`) means supported. + + +## SubagentUpdate + +**UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +Notification that the enclosing parent session created and owns a child session. + +Later updates modify the existing association's metadata, not its ownership. + +Sent on the immediate parent session. The first update for an unknown +`SubagentUpdate::session_id` announces the child and MUST be sent +before any live request or notification bearing the child's session ID, +including messages naming it as sender or recipient. Child events are +delivered automatically; no separate child load, resume, or subscription is needed. +Understanding this update, registering child sessions, and applying their +operation restrictions are baseline v2 requirements; no Client capability +is required. + +Only `SubagentUpdate::session_id` is required. Other fields have +patch semantics: omitted fields leave the stored value unchanged, `null` +clears or unsets the value, and concrete values replace it. For `state`, +`null` removes the current state report and leaves activity unconfirmed; +it does not report idle or cancellation. A child whose capabilities are +unset permits no Client-initiated session mutations. + +The title and description provide the parent's display metadata for the +child. Agents SHOULD mirror known child state changes in the parent's +`SubagentUpdate::state` using the same `StateUpdate` snapshot as the +ordinary `state_update` notification on the child session. This reports +child state, not a separate parent-owned lifecycle. Consumers should +treat duplicate reports idempotently. +Completing or cancelling work does not end the association: the parent may +message the same child again. Individual messages and their outcomes do not +change this association. + +**Type:** Object + +**Properties:** + + + The _meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Omitted means no metadata update; `null` is an +explicit clear signal. Implementations MUST NOT make assumptions about values at these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/draft/extensibility) + + +SubagentSessionCapabilities | null} > + Client-initiated session mutations permitted for this subagent session. + +Read-only operations retain their normal protocol semantics and +capability requirements. + + + + The parent's human-readable description of the child's role or purpose. + +Optional and nullable. Omitted means unchanged; `null` clears it. This is +current display metadata, not the history of instructions sent to the child. + + +SessionId} required> + The opaque session ID identifying the child in all ACP messages. + +Nested inside `update`; the enclosing notification's `sessionId` identifies +the immediate parent, not this child. + + +StateUpdate | null} > + The child's current foreground state, mirrored onto its parent association. + +Optional and nullable. Omitted means unchanged; `null` removes the +current report and leaves activity unconfirmed (not idle or cancelled). +A concrete `StateUpdate` replaces the entire previous snapshot. + + + + The parent's human-readable display title for this child. It need not be unique. + +Optional and nullable. Omitted means unchanged; `null` clears it. If no +title is set, the Client chooses a fallback presentation. + + + ## Terminal A display-only reference to an agent-owned terminal. @@ -9910,6 +10301,32 @@ future ACP variants. +## UnknownStateUpdate + +**UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +The Agent cannot currently determine foreground activity. + +Report this when activity becomes unobservable, not merely because the session +has been quiet. The Client MUST stop presenting the previous state as confirmed +current activity, but may retain it as last known. A later state replaces this +snapshot normally. + +This is not a task outcome or session closure. It does not cancel work, resolve +pending requests, or revoke capabilities; capabilities are updated separately. + +**Type:** Object + +**Properties:** + + + The _meta property is reserved by ACP for additional metadata. Implementations + MUST NOT make assumptions about values at these keys. Optional; omitted and + `null` mean no metadata for this state snapshot. + + ## Usage **UNSTABLE** diff --git a/docs/rfds/subagents.mdx b/docs/rfds/subagents.mdx new file mode 100644 index 000000000..0a9208ff7 --- /dev/null +++ b/docs/rfds/subagents.mdx @@ -0,0 +1,1185 @@ +--- +title: "Subagent Sessions" +description: "RFD for exposing agent-created subagents as restricted ACP sessions" +--- + +Author(s): Vadim Briliantov + +## Elevator pitch + +> What are you proposing to change? + +Allow an Agent to expose subagents that it creates when delegating work. Each +subagent is represented by its own ACP session ID, so its messages, thoughts, +plans, tool calls, and current work can be displayed independently from the parent +session. + +The Agent associates a reusable child session with its parent through an +upsert-style `subagent_update`. Child events then flow automatically on the +same connection. Messages between sessions use `session_message` upserts or +`session_message_chunk` notifications with normal message content and optional +sender and recipient metadata, rather than tool-call content. + +The association is not a task or a one-shot lifetime. The parent can message +the same child again after earlier work finishes. Client-initiated session +mutations remain disabled unless explicitly advertised for that child; this +proposal initially defines only cancellation of current work. + +## Status quo + +> How do things work today and what problems does this cause? Why would we change things? + +ACP models a conversation as a single session update stream. Agents can run +work concurrently internally, but there is no portable way to tell the Client +that an update came from a subagent or to describe the relationship between the +two sessions. + +Agents therefore have to flatten subagent activity into the parent session, +hide it, or encode it in custom tool calls. Clients cannot reliably: + +- render concurrent work as separate activity; +- associate plans, messages, and tool calls with the worker that produced them; +- distinguish a worker's current title from the history of tasks sent to it; +- track nested subagents; +- cancel one subagent without cancelling the parent turn; or +- distinguish an idle child from one that is still processing work. + +Treating a subagent as an ordinary user-facing session is also inaccurate. It +would imply that Clients can call methods such as `session/prompt`, or future +queueing and steering methods, even when the underlying runtime has no way to +accept user input for that worker. + +## What we propose to do about it + +> What are you proposing to improve the situation? + +### Capability negotiation + +In v1, add an optional `subagents` object to `ClientCapabilities`. A non-null +object means the Client understands the association updates, session-directed +messages and chunks, and restricted-session semantics in this RFD. The field +is optional and nullable, like adjacent capability objects. Omission or `null` +means the capability is not supported; `{}` means it is supported. + +```json +{ + "clientCapabilities": { + "subagents": {} + } +} +``` + +In v1, an Agent **MUST NOT** send the updates defined by this RFD +unless the Client advertised `subagents`. It may still use +subagents internally and present their results through the parent session. + +There is no Agent-side capability. Subagent visibility flows only from Agent to +Client, so the Client capability alone is enough to prevent sending updates the +other side cannot understand; a Client learns that a particular Agent exposes +subagents by receiving the first update. In ACP v2, subagent updates are part +of the baseline session model and support is assumed rather than negotiated; +see [Subagents in ACP v2](#subagents-in-acp-v2). + +### Announcing and updating an association + +`subagent_update` notifies the Client that the enclosing parent session has +created and owns another session. The first update for an unknown +`update.sessionId` announces that ownership association; later updates patch +its metadata without creating a new child or transferring ownership. This is +not a message to the child or merely a link to a related session. + +When an Agent creates a subagent that it wants to expose, it **MUST** send the +announcing `subagent_update` before sending any request or notification bearing +the child's session ID, including any live session message naming it as sender +or recipient. This includes permission, elicitation, filesystem, and terminal +requests where supported by the protocol version: + +```json +{ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "sess_parent", + "update": { + "sessionUpdate": "subagent_update", + "sessionId": "sess_child_1", + "title": "Test investigator", + "description": "Investigates platform-specific test failures.", + "capabilities": { + "cancel": {} + } + } + } +} +``` + +Only `update.sessionId` is required: + +- `update.sessionId` is an opaque `SessionId` unique within the ACP + connection. It identifies the same child across repeated delegations and + **MUST NOT** be reused for an unrelated session. +- `title` is an optional human-readable label for the child in its parent's + view. It need not be unique; the Client chooses a fallback if none is supplied. +- `description` is an optional human-readable description of the child's role + or purpose. It is current display metadata, not a transcript of instructions. +- `capabilities` optionally describes the Client-initiated session mutations + permitted for this specific child session. `cancel` is an optional, + nullable capability object: omitted or `null` means unsupported, while `{}` + advertises support. The object may include optional nullable `_meta`; omitted + or `null` `_meta` means no capability metadata. If + `capabilities` was never supplied, no session mutations are permitted. +- `state` is an optional current-work snapshot, defined in + [Current work state](#current-work-state). It is not a lifecycle outcome. +- `_meta` optionally carries association metadata. + +In both versions, these optional fields are nullable patches: omission means +unchanged, `null` clears the field, and a concrete value replaces the whole +previous field. Clearing `title` or `description` removes the parent's display +override. Clearing `state` leaves current activity unconfirmed, not idle. +Both `capabilities: null` and `capabilities: {}` disable Client-initiated +mutations. + +The parent stream carries `title` and `description` so Clients can maintain a +subagent roster without collecting metadata from every child stream. These are +the parent's labels for its child. The child's ordinary `session_info_update` +can still describe its own conversation; it does not patch the parent +association's display metadata. Updating either title does not rewrite the +content of earlier messages or operations. + +The `capabilities` object is replaced as a whole, not patched recursively. +For example, `capabilities: { "cancel": null }` disables individual cancellation +in either version. Outer `capabilities: null` clears the entire capability set. +Capability advertisements inside that set are not patches: omitted or `null` +`cancel` means unsupported, not unchanged. + +The outer `sessionId` establishes the immediate parent. This supports arbitrary +nesting without adding a second parent identifier. If a child spawns another +subagent, the Agent sends `subagent_update` with the child's session ID as the +outer `sessionId`. A child has one immediate parent; the association cannot +reparent an existing session, refer to itself, or create a cycle. + +This association describes ownership and management, not the set of sessions +that may interact with the child. Session-directed messages describe communication +and may go from a child back to its parent or to another known session +without changing the ownership tree. + +Session identity follows the exposed conversation, not an individual provider +task, turn, or tool call. Completing a task does not create a new conversation. +Provider IDs can be used directly when their scope and lifetime meet the +identity requirements above; otherwise adapters can derive suitable ACP IDs +or maintain a mapping. + +Announcement ordering also applies to short-lived work. Clients attribute +subsequent traffic to the announced child and apply its restricted-session +semantics; routing internals and presentation are implementation choices. +SDKs may tolerate non-conforming Agents by buffering early traffic, but that +tolerance does not replace the Agent's announcement obligation. + +Provider callbacks need not arrive in announcement order. If an interaction +arrives before the corresponding spawn metadata, the adapter can announce a +minimal association as soon as it knows the child's identity and immediate +parent, or buffer the interaction until it can do so. It **MUST NOT** guess +parentage or temporarily announce a nested child under the root. + +Exposure is optional. If the runtime cannot supply sufficient identity and +parentage without blocking the operation, the Agent may keep that operation +unexposed and represent it through the parent instead. It **MUST NOT** later +move an already-issued interaction to a different session. Once a child is +exposed, new interactions known to belong to that child's work **MUST** use its +session ID rather than silently falling back to the root. A parent's own +request for permission to create or delegate to a child remains a parent +interaction; it does not require announcing a child that does not yet exist. + +The child's execution context — its working directory, MCP servers, and +available tools — is chosen by the Agent and is not guaranteed to match the +parent's. An Agent may, for example, run a child in a scratch directory or +without the parent's MCP servers. Whatever the context, all child activity +flows through the same ACP connection: Agent-to-Client requests made on behalf +of a child are subject to the same Client capabilities and permission checks as +the parent's, and the Agent **MUST NOT** use a child to circumvent restrictions +the Client imposed on the parent session. A future RFD may add explicit +per-child execution-context fields if Clients need to display or negotiate +them. + +### Automatic child event delivery + +Announcement registers the child for routing on the existing ACP connection. +The Agent then sends child events without waiting for another Client request. +This RFD does not introduce `session/attach`, `session/subscribe`, or a +per-child event opt-in. Clients **MUST NOT** call `session/load` or +`session/resume` on a child to begin receiving its events. + +Automatic delivery is an ACP contract, not an assumption about the provider +API. Depending on the provider, adapters may need to enable child output, +attach listeners, or restore subscriptions when reconnecting. A provider that +already delivers the necessary events requires no additional mechanism. + +Existing session updates are addressed to the child in the normal way. For +example, the child can report its own conversation title separately from the +parent's roster label: + +```json +{ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "sess_child_1", + "update": { + "sessionUpdate": "session_info_update", + "title": "Windows integration test failure" + } + } +} +``` + +Updates from the parent and any number of children may be interleaved. Ordering +is defined by the transport order within each session, subject to the +announcement requirement above. In v2, Agents **MUST NOT** place an announcement +and traffic that depends on it in the same JSON-RPC batch, since batch entries +may be processed in any order. + +Subagents may use Agent-to-Client methods such as filesystem, terminal, and +permission requests when the Client advertised those capabilities. A subagent +session is therefore not "read-only" with respect to the workspace. It is +restricted only in the direction of user interaction: the Client observes it +and may use explicitly advertised controls, but cannot submit new +work to it. Existing permission and security boundaries apply equally to parent +and child activity. + +### Session-directed messages + +Add `session_message` and `session_message_chunk` variants to `SessionUpdate` +in both versions. These report a session's outgoing or incoming messages to or +from another session, not responses addressed to the human user and not tool +calls. They use the existing `session/update` notification, not a new JSON-RPC +method or a new `ContentBlock` type. + +The enclosing `params.sessionId` identifies the transcript being updated, not +necessarily the sender. Each update has: + +- `messageId`: required, non-nullable, and unique within the enclosing session, + just like other message IDs. It identifies the entry in that transcript, not + a globally shared message or either participant. +- `senderSessionId`: optional and nullable. Identifies the sending session + when supplied. +- `recipientSessionId`: optional and nullable. Identifies the receiving session + when supplied. +- `content`: a `session_message` may replace the whole `ContentBlock[]`; + `session_message_chunk` requires one non-nullable `ContentBlock` to append. + Normal text, images, audio, and resource content use their existing shapes. +- `_meta`: optional and nullable. It updates message-scoped metadata on + `session_message`; it is chunk-scoped on `session_message_chunk`. + +Agents **SHOULD** include participant IDs on the first message update or chunk +when available. Later events may omit them or supply previously missing +identities. For these identity fields, omission and `null` mean not supplied: +the Client retains any previously reported value. They do not use the +null-clearing semantics of the mutable `content` and `_meta` fields. + +Known participant information must remain consistent with the enclosing +transcript and earlier updates for that message. When both IDs are known, +they identify different sessions and the outer `params.sessionId` matches one +of them. Matching the sender represents the outgoing view; matching the +recipient represents the incoming view. Supplied live participant IDs refer +to sessions known through ordinary setup or a `subagent_update` announcement; +missing participants do not prevent displaying the message. + +Whole-message updates use the same patch convention in both versions: +omitted `content` and `_meta` leave the stored value unchanged, `null` clears +the field, and concrete values replace the whole field. `content: []` also +clears accumulated content. New messages start with empty content and no +metadata when those fields are omitted. Chunk `_meta` is not a message patch: +omitted or `null` means no metadata for that chunk. + +Clients should render outgoing entries as "To …" with a recipient link and +incoming entries as "From …" with a sender link. Both use normal message +content while remaining distinct from user messages and answers to the user. +Neither needs a tool-call card, title, execution status, or a raw-input viewer. +When participant metadata is insufficient, Clients can use a generic +inter-session presentation and add links when identities arrive. Missing +metadata is not evidence of human authorship or confirmed receipt. + +For example, the parent's outgoing view of a message to its child is: + +```json +{ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "sess_parent", + "update": { + "sessionUpdate": "session_message", + "messageId": "msg_to_child_1", + "senderSessionId": "sess_parent", + "recipientSessionId": "sess_child_1", + "content": [ + { + "type": "text", + "text": "Also check whether the failure occurs on Windows." + } + ] + } + } +} +``` + +If the Agent observes that message in the child's conversation, it can report +the incoming view using the same shape. The sender and recipient stay the +same; the outer session and transcript-local message ID change: + +```json +{ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "sess_child_1", + "update": { + "sessionUpdate": "session_message", + "messageId": "msg_from_parent_1", + "senderSessionId": "sess_parent", + "recipientSessionId": "sess_child_1", + "content": [ + { + "type": "text", + "text": "Also check whether the failure occurs on Windows." + } + ] + } + } +} +``` + +These notifications report Agent-owned communication; the Client does not +forward the content or invoke `session/prompt` because it received one. Neither +form acknowledges recipient processing or establishes new ownership. Actual +delivery is handled by the Agent. + +The Agent reports each side only when it can observe that side: an outgoing +entry is not sufficient evidence to manufacture an incoming entry. Clients +**MUST NOT** infer receipt or synthesize an Agent-reported incoming transcript +entry solely from an outgoing entry. This does not prevent a clearly labeled +projection of outgoing messages in a cross-session view. The two views have +independently scoped message IDs, and Clients +**MUST NOT** assume equal IDs identify the same communication across sessions. +No cross-session message-correlation ID is defined here. + +An incoming entry uses this message kind, not an additional ordinary user +message that would misattribute the communication. Rendering the same entry +in multiple views or quoting it in a summary is a presentation choice. Its +content reflects what was observed in that conversation; it need not match +the outgoing view byte for byte if the Agent transformed the content during +delivery. Neither view indicates that the recipient finished processing the +message. + +Normal tool calls remain available for actual tools, including tools that +perform delegation or waiting. They report those operations' inputs, outputs, +and outcomes. Agents do not need to invent a tool call to report a directed +message, and this RFD adds no session-reference variant to `ToolCallContent`. + +#### Streaming + +A directed message may start with a chunk on either side; a prior whole-message +notification and complete participant metadata are not required: + +```json +{ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "sess_child_1", + "update": { + "sessionUpdate": "session_message_chunk", + "messageId": "msg_2", + "content": { + "type": "text", + "text": "Start by reproducing " + } + } + } +} +``` + +Subsequent chunks append normal message content for the same transcript-local +`messageId`. Participant metadata can arrive with a later chunk or a +metadata-only upsert, without repeating or replacing the content: + +```json +{ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "sess_child_1", + "update": { + "sessionUpdate": "session_message", + "messageId": "msg_2", + "senderSessionId": "sess_parent", + "recipientSessionId": "sess_child_1" + } + } +} +``` + +A `session_message` is an upsert: when it supplies a content array, that array +replaces all content accumulated for the message rather than appending. It can +also update metadata without resending content. Later chunks append to the +stored content; chunk metadata does not patch message metadata. + +### Why session IDs appear in different places + +These IDs answer different questions; they are not competing ways to route +the same event: + +| Location | Purpose | +| ---------------------------------------------------------------- | ---------------------------------------------------------------- | +| Outer `params.sessionId` | Which session's transcript or entities does this update address? | +| `params.update.sessionId` in `subagent_update` | Which child is owned by the parent named by the outer ID? | +| `params.update.senderSessionId` in a session message or chunk | Which session sent, or is sending, this message? | +| `params.update.recipientSessionId` in a session message or chunk | Which session is this message addressed to? | + +For example, with parent `sess_parent` and child `sess_child_1`: + +1. An association update has outer `sessionId: "sess_parent"` and + `update.sessionId: "sess_child_1"`. This registers the parent-child + relationship and the child's capabilities. +2. An outgoing message has outer `sessionId: "sess_parent"`, + `senderSessionId: "sess_parent"`, and `recipientSessionId: "sess_child_1"`. + This updates the parent's history with a link to the child. +3. The received view has outer `sessionId: "sess_child_1"` and the same sender + and recipient. Its independent message ID identifies the entry in the + child's history, where the Client can link back to the parent. +4. The child's own messages, plans, and tool calls have outer + `sessionId: "sess_child_1"`. They update the child's history, not the + parent session. V2 work-state notifications use this child-addressed stream + and are mirrored on the parent association; v1 reports work state through + the association as described below. +5. A reply reverses the sender and recipient, not the ownership association. + The Agent can report its outgoing view in the child's transcript and its + incoming view in the parent's transcript. + +Communication links can point both ways without making the ownership tree +cyclic. Neither participant ID replaces the outer routing ID or the separate +ownership announcement. The Client may navigate between the conversations +without treating a message as a transfer of ownership or control. + +### Current work state + +There is no separate subagent lifecycle enum. Work can start and stop many +times within the same child session. Becoming idle, completing an operation, +or cancelling current work does not permanently end the association. + +V2 uses the child's ordinary `state_update` notifications and their existing +foreground-work semantics. Agents **SHOULD** also mirror each reported child +state on the immediate parent's `subagent_update.state`, so both the parent's +roster and the child's own stream carry the information. + +These are two reports of one logical child state, not independent lifecycles. +Mirrored reports use the same `StateUpdate` snapshot and are idempotent: a +second copy does not represent additional work, completion, or token usage. +Copies of a transition precede later transitions on either path, so a delayed +mirror cannot overwrite newer state. + +For example, a v2 child reports: + +```json +{ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "sess_child_1", + "update": { + "sessionUpdate": "state_update", + "state": "idle", + "stopReason": "end_turn" + } + } +} +``` + +The parent-carried snapshot has the same shape in both versions. V2 emits it +alongside the child's notification; v1 uses it because it has no equivalent +child notification or Client-issued child prompt response: + +```json +{ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "sess_parent", + "update": { + "sessionUpdate": "subagent_update", + "sessionId": "sess_child_1", + "state": { + "state": "idle", + "stopReason": "end_turn" + } + } + } +} +``` + +The v1 payload mirrors the v2 state object: + +- `running`: foreground work is in progress. +- `requires_action`: foreground work is blocked on user action. +- `unknown`: the Agent cannot currently determine foreground activity, for + example after losing a remote child's event feed while the ACP connection + remains live. This is a nonterminal work state, not a work outcome. +- `idle`: foreground work is not in progress; the parent may assign more work. + Optional nullable `stopReason` uses v1's existing `StopReason`. Omitted or + `null` means no reason was reported. When the draft end-turn usage feature is + supported, optional nullable `usage` follows the same convention. +- Each known state may contain optional nullable `_meta`; omitted and `null` + both mean no metadata for that snapshot. + +When the v1 Agent observes child foreground work starting, resuming, or +stopping, it **MUST** report the corresponding `running` or `idle` state. +It **SHOULD** report `requires_action` while observed foreground work is +blocked on user action, and include `stopReason` when the reason for stopping +is known. This does not require reconstructing unobserved intermediate +transitions or guessing activity from provider callbacks. Cancellation uses +`idle` with `stopReason: "cancelled"`, not a terminal child state. + +When observability of child foreground work is actually lost, the Agent +**MUST** report `unknown` using the existing state payload: v1 sends +`subagent_update` with `"state": { "state": "unknown" }` on the parent stream; +v2 sends `state_update` with `"state": "unknown"` on the child stream. Silence +alone is not evidence of lost observability. V2 mirrors this snapshot on the +parent association as well. The Client **MUST NOT** continue +presenting an earlier `running` or `requires_action` as confirmed live activity +after `unknown`, though it may retain that last-known value for context. A +subsequent `running`, `requires_action`, or `idle` replaces `unknown` for the +same child ID. `unknown` neither closes or cancels the child nor resolves +pending requests; it does not automatically revoke mutation capabilities. +If a control is no longer available, the Agent updates `capabilities` +separately. + +The association's `state` field is a replacement snapshot, not a nested patch: +omission leaves the previous snapshot unchanged, `null` clears it, and a +concrete object replaces it entirely. For example, `{ "state": "running" }` +replaces an earlier idle snapshot and its stop reason. If no state has been +reported, or the snapshot is cleared, the Client has no confirmed current-work +state; neither announcement nor clearing implies `running`, `idle`, or +cancellation. A later state report on either supported path can supply the +current snapshot again. Unrelated association updates need not repeat it. + +Unrecognized state objects preserve both their discriminator and payload. Values +beginning with `_` are implementation-specific; other unknown values are +reserved for future ACP states. An unknown value does not imply completion or +closure. V2 keeps its open state and stop-reason handling, with `unknown` added +to ordinary `StateUpdate` under the experimental `unstable_subagents` feature. + +Idle is not a successful task outcome or a declaration that the session has +been released. As in ordinary v2 sessions, background activity may still emit +updates while foreground work is idle. Operation results and failures are +reported through the relevant tool calls or other ordinary session output. + +### Connection loss + +Connection availability is separate from reported work state. After losing +the ACP connection, the Client **MUST NOT** continue presenting a last-known +running state as confirmed live activity, or infer that unfinished work +succeeded, failed, or was cancelled. It may show the child as disconnected or +its current activity as unknown while retaining recorded outcomes. This +Client-local uncertainty differs from an Agent-reported `unknown` state while +ACP is connected. + +This is local presentation, not a synthesized wire state or a permanent change +to the session's history. Fresh state reported after reconnecting is +authoritative and may be `running` again for the same child ID. Neither the +parent's prompt response nor its transition to idle implies a lost connection +or a stopped child. + +### Reconnection and replay + +The Client reconnects through the ordinary parent session. Direct child +`session/load` and `session/resume` are not supported by this proposal. +The Agent decides whether parent reconnection reattaches surviving child +runtimes, restores them, or only makes their recorded history available. +The protocol does not prescribe a runtime-recovery algorithm. + +The replay entry point depends on the protocol version: + +- **ACP v1:** `session/load` requests history replay. `session/resume` restores + the parent without replaying history. +- **ACP v2:** `session/resume` with `replayFrom: { "type": "start" }` requests + full history replay. Omitted or `null` `replayFrom` means no history replay. + v2 has no `session/load` method. + +The parent session's ordinary replay obligations are unchanged. In addition, +the Agent **SHOULD** replay available recorded associations and child history +on a best-effort basis. Provider retention, process restarts, and missing +transcripts may leave some or all child history unavailable; complete child +history is not a prerequisite for supporting live subagent sessions. + +The following rules still apply to whatever is replayed: + +- Replayed child updates **MUST** preserve their original session identities, + known parentage, and per-session ordering. Child announcements precede child + traffic. +- If identity and parentage are recoverable but conversation content is not, + the Agent may replay only the association. This is historical context, not + evidence of a live runtime or a complete transcript. +- If an association cannot be reconstructed truthfully, the Agent **MUST NOT** + guess its parent or issue traffic for an unannounced child. It may omit that + child's own replay. +- A recorded directed message in a known session's history may still be + replayed with its available participant metadata and content when the + other participant's association or history is unavailable. This applies to + incoming as well as outgoing entries. A historical address is not an + announcement. Clients **MUST NOT** infer ownership, live activity, or controls + from it; they can show the other participant as unavailable until its session + becomes known. Agents **MUST NOT** invent either participant ID or reinterpret + the content as an ordinary message to or from the human user. +- The Agent **SHOULD** make known gaps apparent through a supported advisory + mechanism rather than imply that child replay is complete. Missing history + is not evidence that a child never existed, stopped, failed, or was cancelled. +- All history selected for this replay **MUST** be sent before the response. + The response completes the best-effort child replay; it does not certify + that every child record was available. Delayed historical child state must + not be sent after that boundary as if it were fresh activity. + +Adapters **SHOULD** retain or reconstruct child identity, associations, and +ACP-visible conversation data where practical. This does not require a durable +copy of every child event. Replay can use provider transcripts or retained +snapshots rather than reproduce the original wire chunking. These best-effort +history rules do not weaken identity or announcement requirements for live +child traffic after reconnection. + +Capability negotiation still applies during replay. V1 Clients that did not +advertise `subagents` receive ordinary parent history, not unsupported child +updates or session-directed message variants. Parent-level results and +summaries can still be replayed. + +Reconnection separates routing context from current work state and controls. +When loading or resuming a parent, the Client **MUST** invalidate cached live +work state and mutation authorization for its children. Historical data may +remain visible, but effective child capabilities start empty and current work +state starts unconfirmed. Historical capability patches never populate that +effective set; omitting a current capability field cannot revive an earlier +`cancel` grant. The Client **MUST NOT** enable child mutations until the +load/resume succeeds and a current capability grants them. If the operation +fails, current state and authorization received during that attempt are +invalidated. + +The Client need not retain an earlier session tree. Before using a child in +live traffic, the Agent establishes the child's complete ancestor chain in +parent-before-child order during this load/resume. Replayed associations can +provide that routing context and need not be repeated merely because the +response has been sent. Missing intermediate ancestors still need announcing, +even if their runtimes were not restored; an association establishes identity, +not a live runtime. + +**When replay is requested**, the successful response is the freshness boundary +for child work-state and mutation-capability snapshots: + +- Before the response, these snapshots are historical. They **MUST NOT** + authorize Client-initiated mutations or establish confirmed current activity. +- Current snapshots are sent after the response. Normal association and state + updates suffice; no extra announcement phase is needed for already-known + associations. An Agent can omit capabilities when none are enabled, because + the effective set starts empty rather than inheriting historical grants. +- In v2, the response and current snapshots that depend on this boundary + **MUST NOT** share a JSON-RPC batch. + +Replay **MUST NOT** reissue recorded permission, elicitation, or other +Agent-to-Client requests. Newly issued requests are live and may proceed once +their child and ancestors are announced, under ordinary request handling. +Responding to them does not enable historical Client-initiated controls. +Other live transcript traffic can also proceed if the Agent preserves +per-session ordering and ensures subsequent replay cannot overwrite or +duplicate newer live content. Buffering affected traffic is one way to meet +those requirements, not a requirement to stop every child stream. + +**When no replay is requested**, announcements, current snapshots, and ordinary +child traffic may arrive before or after the resume response, subject to +announcement ordering. These snapshots are current observations, not history +replay; controls still wait for successful resume. Agent-to-Client requests +retain their ordinary handling; their responses need not wait for resume to +finish. + +For example, if `root` → `coordinator` → `worker` was previously announced +and only `worker` resumes, the Agent establishes `coordinator` under `root`, +then `worker` under `coordinator`, before sending fresh `worker` traffic. +Replay may already have supplied these associations. Without replay they can +be announced before the resume response. A routing-only ancestor can omit +capabilities and work state; it has no enabled controls or confirmed activity. +The worker is not reparented directly under `root`. + +The Agent **SHOULD** report current child work state when it can determine it. +Historical running states are not proof of current activity. An Agent **MUST +NOT** manufacture a failure, cancellation, or permanent orphan outcome solely +because the persisted history lacks a final update or a runtime could not be +restored. A history-only child remains displayable, with unconfirmed current +activity and no enabled mutations. + +### Restricted session methods + +Client-initiated methods that modify a child session or its runtime are +disabled unless explicitly enabled by that child's capabilities. This is an +allowlist, not a list of individual prohibited methods: Clients **MUST NOT** +invoke an unadvertised mutation, including a future method, merely because the +Agent supports it for ordinary sessions. + +Mutations include prompting, queueing, steering, cancellation, closing, +deletion, mode or configuration changes, and forking. Loading or resuming also +changes runtime attachment and is not a read-only history query. The only +mutation capability defined by this RFD is `cancel`; direct child load, resume, +and close are therefore not allowed. The parent Agent's own communication with +its children is not a Client-initiated mutation and is not restricted by these +capabilities. + +Read-only operations retain their normal protocol semantics and capability +requirements; this RFD does not grant access to additional sessions or define +a new history-query method. Responses to Agent-to-Client requests are not +Client-initiated session mutations and **MUST** still be delivered. + +Restricted children are discovered through parent associations rather than +ordinary standalone `session/list` entries. This avoids offering load, resume, +or prompt controls that are not available for them. + +If forking a parent copies child history, the Agent **MUST** consistently remap +the copied session IDs in both message participant fields, including references +to the forked root. Each copied child still has one parent and a distinct +identity. Participants outside the copied tree do not become part of the copy; +unavailable participants follow the historical replay rules above. Copying +history does not itself create live child runtimes. Parent deletion keeps its +ordinary history-management semantics; it is not a substitute for closing +active sessions. + +### Cancellation and resource ownership + +If `capabilities.cancel` is a non-null object, the Client may send the existing +`session/cancel` notification with the child's session ID. This cancels current +work in that child and its active descendants, not its parent or siblings. +Cancelling a parent likewise cascades to its active descendants. The per-child +capability controls individual Client cancellation; it does not prevent the Agent +from cancelling its own delegated work. + +An omitted or `null` per-child `cancel` capability does not waive ancestor +cancellation. Adapters should verify whether the provider already supplies +that cascade or needs additional descendant cancellation. Advertise individual +cancellation only when the adapter can target the child's current work +correctly in that runtime mode. + +Cancellation retains ordinary session semantics: abort the affected work and +send its pending updates before confirming cancellation. A child confirms +cancellation with `idle` and `stopReason: "cancelled"` through its v1 state +snapshot or ordinary v2 notification, mirrored on the v2 parent association. +There is no Client-issued child +`session/prompt` response to wait for. A cancellation racing with already-ended +work does not rewrite that work's outcome. + +Acceptance of a provider stop/interrupt command is not, by itself, evidence that +the work has stopped. The Agent reports cancellation from actual work-state or +completion evidence, not merely from a successful command acknowledgement. If +activity becomes unobservable, it reports `unknown` rather than inventing a +cancelled outcome. + +The Client **SHOULD** optimistically mark the affected unfinished tool calls as +cancelled, as specified for [v1 prompt cancellation](/protocol/v1/prompt-turn#cancellation) +and [v2 work cancellation](/protocol/v2/prompt-lifecycle#cancellation). It +**SHOULD** still accept subsequent Agent updates for those calls. This local +presentation is not a new v1 wire-level tool status. + +Cancellation does not close the child session or prevent the parent from +assigning later work to it. Completing or failing one operation also does not +close the session or automatically fail its parent. + +The Agent owns child resources. Closing the ordinary parent session cancels +its active descendant work and releases the associated active resources under +the existing close contract. It does not erase child history or require a new +terminal-state notification. Whether child runtimes can later be restored is +left to the Agent. + +### Pending permission and elicitation requests + +Optimistically updating a tool's display does not resolve its JSON-RPC +requests. When the Client cancels child work, directly or through an ancestor, +it **MUST** respond to pending `session/request_permission` requests for the +affected work with the `cancelled` outcome, following the existing cancellation +rule. It **SHOULD** dismiss affected elicitation controls and respond with the +`cancel` action. The same cleanup applies when closing the parent. + +Generic [`$/cancel_request`](/protocol/v1/cancellation) remains optional. +Agents may use it to cancel an abandoned child request, including when the +Agent stops work on its own. Advertising subagent support does not add a +requirement to implement that generic mechanism. + +If the Client learns that an interactive request's associated work has ended +and the request no longer applies, it **SHOULD** dismiss the control and send +the corresponding cancellation response. Idle alone is not a cancellation of +unrelated background requests. Requests remain attributed to their original +child session and **MUST NOT** move to the parent or a later operation. + +A late response still resolves its original request, but the Agent **MUST NOT** +use it to restart cancelled or otherwise abandoned work, or to authorize a new +assignment to the same child. A request **MUST NOT** receive more than one +response. There is no new child-termination barrier that requires every +outstanding request in a reusable session to resolve before reporting work +state. + +### Cost reporting without inferred aggregation + +Exposing child sessions does not make existing `usage_update.cost` values +exclusive or additive. Cost reporting remains optional. An Agent may continue +reporting the provider's cumulative cost for the parent session even when it +includes child work or internal helper calls. Exposing subagents does not +require changing that accounting scope, producing an exclusive breakdown, or +discarding a useful inclusive total. + +Child costs remain optional and may overlap the parent's reported cost. +Missing child cost is unknown, not zero. Agents **MUST NOT** fabricate an +exclusive parent or child amount from incomplete token counts or an aggregate +total. + +Clients **MUST NOT** infer a combined tree total by adding parent and child +values, or infer exclusive costs by subtracting them, unless a separate +accounting contract establishes their coverage and how any overlap is handled. +Clients may display each session's latest reported cumulative value without +claiming an accounting scope that has not been established. Successive +cumulative updates and repeated messages to a session are not additional costs. + +For example, a parent's reported USD 1.20 may already include a child's USD +0.30. Both values can be shown, but the Client must not synthesize USD 1.50 as +the combined cost from the session tree alone. A missing cost for another child +does not invalidate the provider's USD 1.20 report. + +This RFD adds no cost-scope field or accounting negotiation. A future usage +extension can define explicit inclusive/exclusive breakdowns where available. +Amounts in different currencies must not be added without conversion under +such an accounting contract. Context-window usage remains local to each session +and is not summed across the tree. + +### Subagents in ACP v2 + +The two versions use the same ownership and directed-message model, with +these differences: + +- **Baseline support, no capability.** v2 Clients **MUST** understand + `subagent_update`, apply child operation restrictions, understand directed + message snapshots/chunks, and attribute requests and notifications to the + correct sessions. + Clients may choose not to render a dedicated subagent UI, but cannot merely + ignore the announcement as an unknown update. The `subagents` Client + capability is v1-only. +- **Mirrored child work state.** V2 keeps `state_update` on the child's own + stream and mirrors the same state in the parent association. V1 uses only + the parent-carried snapshot. Both describe the child's ordinary foreground + work, not a separate subagent lifecycle. +- **Resume with replay.** Available child history is replayed best-effort + through `session/resume` with `replayFrom: { "type": "start" }`, not + `session/load`. + +The restricted-session rules, cancellation flow, and connection-loss handling +defined above also apply to v2. The [v2 prompt lifecycle](/protocol/v2/prompt-lifecycle) resolves +`session/prompt` at acceptance and reports foreground work through +`state_update`. Neither that response nor the parent's transition to `idle` +implies that every child has finished. + +## Shiny future + +> How will things play out once this feature exists? + +A Client can render a parent session with a live tree of concurrent workers. +Selecting a worker shows its own plan, messages, tool calls, and status without +mixing them into the main transcript. Where supported, the user can stop a +runaway worker without discarding useful work from its siblings or parent. + +Agents that do not implement subagents remain unchanged. Agents that use +subagents internally but do not want to expose them can continue flattening or +summarizing their output in the parent session. + +## Implementation details and plan + +> Tell me more about your implementation. What is your detailed implementation plan? + +This is a project rollout and validation plan, not a requirement for every +implementation to use the named SDKs, providers, or integration techniques. + +1. Add the v1 `SubagentCapabilities` marker and the `SubagentUpdate` association + and `SubagentSessionCapabilities` types in both versions. +2. Add `SessionMessage` and `SessionMessageChunk` update variants in both + versions, using normal content and optional sender and recipient session + metadata for incoming and outgoing views. +3. Mirror the v2 `StateUpdate` payload in v1 and embed it in the v1 association + update. In v2, mirror ordinary child-session state notifications in the + parent association as well. +4. Keep the additions behind `unstable_subagents` and regenerate schemas and + reference documentation. +5. Ship SDK releases that carry the draft `subagents` capability and update + types — or at minimum preserve them when deserializing and re-serializing — + before adapter rollout. SDKs that strip the draft fields force temporary + out-of-band negotiation bridges such as `_meta`-scoped capability flags. + Such bridges are compatibility shims, not an alternative protocol, and are + retired once SDK support ships. SDK preservation of the draft fields is an + explicit prerequisite for the validation step below. +6. Support the freshness boundary for replayed child state and capabilities. + For example, an SDK could offer an explicit responder or after-response hook + for publishing current snapshots after replay completes. Non-replaying + resume does not need such a hook, and other live child traffic need not be + globally buffered. Any ordering mechanism should provide the required + ordering rather than rely on arbitrary delays. +7. Update example Clients to route child events automatically, retain reusable + session identities, and render session-directed messages distinctly from + user-facing responses and tool calls. Enable or restore provider-side child + delivery where that provider requires it. +8. Establish stable provider-to-ACP session identity, truthful interaction + attribution, and cancellation of current work. Retain or reconstruct child + history where practical, but do not make complete child transcripts a + prerequisite for live exposure. +9. Exercise the [validation scenarios](#validation-scenarios) with captured + native events, including reordered callbacks and reconnects. +10. Validate this revision against both Claude Code and Codex adapter mappings + and a Client with concurrent-session UI before stabilization. Schema tests + and the existence of provider APIs alone are not end-to-end validation. + +### Provider integration notes + +The following inspected versions provide concrete inputs for adapters; they +are not a claim that all versions or runtime modes have equivalent behavior, +or that the existing adapters already implement this revision. + +- **Claude Agent SDK 0.3.280:** the [published declarations](https://unpkg.com/@anthropic-ai/claude-agent-sdk@0.3.280/sdk.d.ts) + expose forwarded child messages through `forwardSubagentText` and + `parent_tool_use_id`, task lifecycle events, permission `agentID` and + `toolUseID`, `stopTask(taskId)`, and child transcript APIs such as + `listSubagents` and `getSubagentMessages`. Adapters may need to correlate + these IDs where their scopes differ from ACP conversation identity. + Permission callbacks can precede spawn metadata. Query cost totals may + already include child and internal work. +- **Codex 0.156.1:** thread/turn identifiers, thread status and approval/input + wait flags, `turn/interrupt(threadId, turnId)`, and thread history support + the corresponding mapping. The server + [attaches listeners to newly created threads](https://github.com/openai/codex/blob/b412ff32c417f855c2b2d1581b77058eed87c84b/codex-rs/app-server/src/lib.rs#L1265-L1283); + adapters should also check delivery for existing threads on reconnect. + Per-thread token usage is not a monetary cost report. + +These integration examples should be checked against the adapter's pinned dependencies. +For example, a provider's generic paused status does not necessarily mean +`requires_action`, and acknowledging an interrupt is not a completed +cancellation. Likewise, a decoder preserving a new field is not sufficient +implementation of child routing or the replay freshness boundary. + +### Validation scenarios + +- Send two assignments to the same child conversation: keep its ACP session + ID, use distinct message IDs, and allow `running → idle → running`. +- Mirror v2 child work state to the parent association. Verify that both + streams converge on the same state and duplicate snapshots do not count + work completion or usage twice. +- Send messages to a parent and a sibling from a child's stream. Verify that + each message has the correct sender, recipient, and content, and that the + parent-child tree does not change. +- Report the received view in the recipient's transcript, with its own message + ID and a link to the sender. Verify that neither the Client nor the adapter + invents an Agent-reported incoming entry from outgoing traffic alone. + Clearly labeled UI projections remain possible, without duplicating the + communication as an ordinary user message. +- Start a directed message with a chunk, interleave another message, and + replace accumulated content with a whole snapshot. Start without participant + metadata, add it later without replacing content, and omit it on subsequent + chunks. Verify that missing metadata remains an inter-session message rather + than being attributed to a human or guessed sender. +- Finish reporting a message before the child finishes processing it, and keep + receiving child events after the parent's prompt response. +- Deliver a child permission callback before spawn metadata, including a + nested child: verify early truthful announcement or buffering, no guessed + parent, and the unexposed-operation fallback when necessary. +- Cancel a child and then an ancestor while interactions are pending: verify + native work is targeted correctly, acknowledgement is not confused with + completion, and late responses cannot authorize later assignments. +- Replay after restarting the adapter with complete, partial, and unavailable + child history. Preserve IDs and relationships for whatever is replayed, + preserve historical senders and recipients without guessing ancestry, and + do not infer an outcome from a gap. Verify that current work state and + controls depend on post-response snapshots, not historical capabilities. + An omitted current capability set must leave controls disabled. +- Issue a live child request during replay after announcing its ancestor chain. + Verify ordinary request handling without enabling historical controls, and + that replay cannot overwrite or duplicate newer live transcript content. +- Resume without history, including current announcements before the response. + Verify that controls remain disabled until success, failed resume invalidates + attempted live state, and routing-only ancestors are retained. No additional + Client attach/subscribe request or universal after-response hook is required. +- Lose a child's activity feed while ACP stays connected: report `unknown` + without inventing an outcome, and accept later state for the same child. +- Report an inclusive parent cost with missing or overlapping child costs: + retain the parent report and do not synthesize a tree total or exclusive + shares from the message traffic. + +## Frequently asked questions + +> What questions have arisen over the course of authoring this document or during subsequent discussions? + +### Is a subagent session read-only? + +Only from the Client's conversational perspective. The Client cannot prompt, +queue, or steer it. The subagent may still read and write files, run terminal +commands, and request permissions using the capabilities already negotiated on +the ACP connection. Making all subagents workspace-read-only would prevent many +coding use cases and should instead be an agent policy or a future sandbox +capability. + +### Do Claude Code and Codex support stopping individual subagents? + +Current Claude Code and Codex orchestration surfaces both have concepts for +stopping or closing an individual worker. That does not mean every version, +runtime mode, or ACP adapter can implement it, and other Agents may only support +coarse parent-turn cancellation. Individual cancellation must therefore be a +capability, not a protocol requirement implied by subagent support. + +The proposal uses a per-child capability because support may even vary within +one Agent: a local worker might be interruptible while a delegated remote job +is not. + +### Why reuse `session/cancel` instead of adding `subagent/stop`? + +Once a child has a real ACP session ID, the existing session-scoped lifecycle +methods already identify it unambiguously. Reusing them avoids two ways to +perform the same operation. The subagent capability narrows where those methods +are valid and defines the required cascade behavior. + +### Why one `subagent_update` type instead of separate spawn and state notifications? + +The upsert announces an association and patches its capabilities without a +separate create method. It also carries the parent's title and description +for the child. Ordinary session updates handle the child's own conversation +and, in v2, foreground work. Both versions carry a state snapshot on the +association so the parent has roster information; v2 mirrors the child's +ordinary notification. Neither version needs a second, permanently terminal +subagent lifecycle. + +### Why are there two `sessionId` fields in an association update? + +The fields have different scopes: `params.sessionId` is the parent whose stream +receives the association update, while `params.update.sessionId` is the child +being associated with it. The tagged `SessionUpdate` payload is flattened +inside `update`, not into `params`, so these names do not collide. Directed +messages instead use `senderSessionId` and `recipientSessionId` for their +participants; the outer `sessionId` chooses the transcript being updated. +See [Why session IDs appear in different places](#why-session-ids-appear-in-different-places). + +### Why is `cancel` the only per-child capability? + +Stopping current work is a useful minimal control and maps to +`session/cancel`. The Agent retains ownership of child resources; the Client +does not need to attach, subscribe, or close each child merely to display it. +The capability object leaves room for future controls without making those +operations available implicitly. + +### Why not subscribe to individual child streams? + +Selective delivery could reduce bandwidth and processing costs for large +session trees, so it is worth revisiting. Clients can already choose which +sessions to render or expand without changing event delivery. + +A future subscription proposal would need to distinguish transcript delivery +from ownership announcements, capabilities, current work state, cancellation, +and permission or elicitation requests. Hiding a transcript must not strand an +interaction or make an unobserved child appear stopped. It would also need +defined catch-up or snapshot behavior when a Client attaches, including gaps +in retained history and ordering relative to live events. + +This RFD keeps automatic delivery and does not add attach/subscribe methods. + +### Why remove the terminal `disconnected` state? + +The earlier draft correctly avoided inventing success, failure, or +cancellation when the outcome was unknown. It unnecessarily made that +uncertainty a permanent property of the child session. Connection availability +and recorded operation outcomes are now separate: Clients show unconfirmed +activity as unknown, and Agents may restore the same child without rewriting +history or pretending to know what happened while disconnected. + +### Why not model a subagent as a tool call? + +The two represent different things. A session owns a conversation; a tool call +reports execution of a tool. An outgoing or incoming message should be +renderable as ordinary content with a recipient or sender link, without +requiring a tool-call card or a raw-input viewer. The directed-message updates +provide that representation and support streaming. Actual tool operations can +still be reported normally, but their status does not define a child's lifetime +or acknowledge that it processed a message. + +### Why not allow prompting or steering a subagent? + +The parent Agent can message or reuse a child according to its runtime's +semantics. This proposal does not expose the same control to the Client: +direct user intervention may be unsupported or interfere with orchestration. +A future per-child capability could opt into it without assuming every Agent +can implement it. + +### What do the association's title and description describe? + +They describe the child in its parent's view, so Clients can show multiple +children directly from parent-session updates. `title` is a short display +label; `description` supplies its role or purpose. The child's conversation may +also have an ordinary session title, which does not replace these parent-owned +labels. + +These fields are not the contents of the latest message sent to the child. +Messages and operations retain their own content and history even when the +parent revises the child's display metadata. There is no session-wide `task` +field that rewrites earlier instructions. + +### Is `session/fork` sufficient for subagents? + +No. Forking is initiated by the Client and creates a normal session derived +from existing context. This RFD covers Agent-created sessions with a parent +relationship and restricted Client controls. An Agent may use a fork +internally to implement a subagent, but the wire semantics are different. + +### What alternative approaches did you consider, and why did you settle on this one? + +- Put a `subagentId` on every existing update. This would duplicate routing + information and require changing every update type. +- Use only tool-call parent/child relationships. This cannot represent nested, + independent session streams cleanly. +- Treat children as unrestricted ACP sessions. This advertises methods that + many agent-created workers cannot accept. +- Add dedicated stop and close methods. Existing session methods already have + the desired addressing and lifecycle semantics. +- Use separate `subagent_spawned` and `subagent_state_update` notifications, as + an earlier draft of this RFD did. Merged into one upsert-style update to + match the v2 entity pattern. +- Treat every completed task as the end of a child session. Rejected because + Agents can reuse a child for later work. +- Require child subscriptions or independent load/resume. Deferred: association + already gives the Client automatic event delivery on the parent connection. + +Representing each child with a session ID reuses the most protocol machinery +while the announcing update and positive capability allowlist capture the +important difference from a user-facing session. + +## Revision history + +- 2026-09-24: Separated reusable session associations from tool-call operations, + added whole and streamed session-directed messages with incoming and + outgoing views, and distinguished parent-owned display metadata from the + child's ordinary conversation metadata. Replaced the terminal lifecycle enum + with v1 work-state snapshots and normal v2 state notifications. Made event delivery automatic, + left runtime restoration to the Agent, retained ordinary optimistic + cancellation without requiring generic request cancellation, and added + nonterminal unknown activity and routing-only ancestor recovery. Clarified + the distinct session ID roles, incorporated provider integration and + validation requirements, and preserved provider-reported costs without + inferred cross-session aggregation. Aligned cancellation with object + capabilities, allowed messages back to parents and other known sessions, + and made child-history replay explicitly best-effort while retaining live + identity and ordering guarantees. +- 2026-09-15: Merged `subagent_spawned` and `subagent_state_update` into a + single upsert-style `subagent_update` following the v2 entity pattern, made + `name`, `task`, and `capabilities` optional, added an explicit `running` + state, removed the Agent-side capability and the per-child `close` + capability, stopped implying that a child inherits the parent's execution + context, and allowed SDKs to buffer updates for unannounced children. Added + the same `subagent_update` to the v2 schema: capability-free, with v2 patch + semantics and an open state enum. +- 2026-08-31: Defined the Client-local `disconnected` state for unknown + outcomes on a live connection, required pending permission and elicitation + requests to resolve before the terminal lifecycle update with defined race + handling, and made SDK preservation of the draft fields an explicit rollout + prerequisite. +- 2026-08-25: Defined child reconnection, replay, orphan, and disconnected + lifecycle semantics. +- 2026-08-19: Initial draft. diff --git a/schema/v1/schema.unstable.json b/schema/v1/schema.unstable.json index dafc52359..d1010cffe 100644 --- a/schema/v1/schema.unstable.json +++ b/schema/v1/schema.unstable.json @@ -5232,6 +5232,54 @@ "$ref": "#/$defs/CompactionSummaryChunk" } ] + }, + { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nAnnounces a child session created and owned by this session, or updates\nthat ownership association's metadata.", + "type": "object", + "properties": { + "sessionUpdate": { + "type": "string", + "const": "subagent_update" + } + }, + "required": ["sessionUpdate"], + "allOf": [ + { + "$ref": "#/$defs/SubagentUpdate" + } + ] + }, + { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA message upsert observed in this session's transcript, sent to or\nreceived from another session.", + "type": "object", + "properties": { + "sessionUpdate": { + "type": "string", + "const": "session_message" + } + }, + "required": ["sessionUpdate"], + "allOf": [ + { + "$ref": "#/$defs/SessionMessage" + } + ] + }, + { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nOne content block appended to a sent or received session message.", + "type": "object", + "properties": { + "sessionUpdate": { + "type": "string", + "const": "session_message_chunk" + } + }, + "required": ["sessionUpdate"], + "allOf": [ + { + "$ref": "#/$defs/SessionMessageChunk" + } + ] } ], "discriminator": { @@ -5998,6 +6046,402 @@ }, "required": ["compactionId", "content"] }, + "SubagentSessionCapabilities": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nClient-initiated session mutations permitted for a specific subagent session.\n\nA mutation requires an explicit per-child capability; support for the method\non ordinary sessions does not grant support on a child.", + "type": "object", + "properties": { + "cancel": { + "description": "Permits the client to cancel this child's current work without ending\nthe session. Omitted or `null` means unsupported; an object (including\n`{}`) means supported.", + "anyOf": [ + { + "$ref": "#/$defs/SessionCancelCapabilities" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "SessionCancelCapabilities": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nCapability to cancel work in a subagent session without ending that session.\n\nSupplying `{}` advertises support; an omitted or `null` `cancel` does not.", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "StateUpdate": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nCurrent foreground-work state of a reusable child session.\n\nEach update is a whole-object snapshot. Idle does not terminate the child;\nthe parent can message it again, transitioning it back to running.\nBackground activity may still emit other session updates while idle.", + "anyOf": [ + { + "description": "Foreground work is in progress.", + "type": "object", + "properties": { + "state": { + "type": "string", + "const": "running" + } + }, + "required": ["state"], + "allOf": [ + { + "$ref": "#/$defs/RunningStateUpdate" + } + ] + }, + { + "description": "The child is ready to process another prompt.", + "type": "object", + "properties": { + "state": { + "type": "string", + "const": "idle" + } + }, + "required": ["state"], + "allOf": [ + { + "$ref": "#/$defs/IdleStateUpdate" + } + ] + }, + { + "description": "Foreground work is blocked on user action.", + "type": "object", + "properties": { + "state": { + "type": "string", + "const": "requires_action" + } + }, + "required": ["state"], + "allOf": [ + { + "$ref": "#/$defs/RequiresActionStateUpdate" + } + ] + }, + { + "description": "The Agent cannot currently determine foreground activity.\n\nThis replaces previously confirmed activity without ending the work or session.", + "type": "object", + "properties": { + "state": { + "type": "string", + "const": "unknown" + } + }, + "required": ["state"], + "allOf": [ + { + "$ref": "#/$defs/UnknownStateUpdate" + } + ] + }, + { + "title": "other", + "description": "Custom or future state.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Other unknown values are reserved for future ACP variants.", + "type": "object", + "properties": { + "state": { + "description": "Unrecognized state discriminator.", + "type": "string" + } + }, + "required": ["state"], + "not": { + "anyOf": [ + { + "type": "object", + "properties": { + "state": { + "type": "string", + "const": "running" + } + }, + "required": ["state"] + }, + { + "type": "object", + "properties": { + "state": { + "type": "string", + "const": "idle" + } + }, + "required": ["state"] + }, + { + "type": "object", + "properties": { + "state": { + "type": "string", + "const": "requires_action" + } + }, + "required": ["state"] + }, + { + "type": "object", + "properties": { + "state": { + "type": "string", + "const": "unknown" + } + }, + "required": ["state"] + } + ] + }, + "additionalProperties": true + } + ] + }, + "RunningStateUpdate": { + "description": "Foreground work is in progress.", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP for additional metadata.\nImplementations MUST NOT make assumptions about values at these keys.\nOptional; omitted and `null` mean no metadata for this state snapshot.", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "IdleStateUpdate": { + "description": "The child is ready to process another prompt.", + "type": "object", + "properties": { + "stopReason": { + "description": "Reason foreground work stopped. Optional; omitted or `null` means not reported.", + "anyOf": [ + { + "$ref": "#/$defs/StopReason" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "usage": { + "description": "**UNSTABLE** Token usage for completed foreground work.\n\nOptional; omitted or `null` means not reported.", + "anyOf": [ + { + "$ref": "#/$defs/Usage" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP for additional metadata.\nImplementations MUST NOT make assumptions about values at these keys.\nOptional; omitted and `null` mean no metadata for this state snapshot.", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "RequiresActionStateUpdate": { + "description": "Foreground work is blocked on user action.", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP for additional metadata.\nImplementations MUST NOT make assumptions about values at these keys.\nOptional; omitted and `null` mean no metadata for this state snapshot.", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "UnknownStateUpdate": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nThe Agent cannot currently determine foreground activity.\n\nReport this when activity becomes unobservable, not merely because the child\nhas been quiet. The Client MUST stop presenting the previous state as confirmed\ncurrent activity, but may retain it as last known. A later state replaces this\nsnapshot normally.\n\nThis is not a task outcome or session closure. It does not cancel work, resolve\npending requests, or revoke capabilities; capabilities are updated separately.", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP for additional metadata.\nImplementations MUST NOT make assumptions about values at these keys.\nOptional; omitted and `null` mean no metadata for this state snapshot.", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "SubagentUpdate": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nNotification that the enclosing parent session created and owns a child session.\n\nLater updates modify the existing association's metadata, not its ownership.\n\nSent on the immediate parent session. The first update for an unknown\n[`SubagentUpdate::session_id`] announces the child and MUST be sent\nbefore any live child traffic or live message naming the child as sender\nor recipient. Parents may message and reuse an announced child across\nmultiple operations.\nChild events are delivered automatically on the same connection; no child\nload, resume, or subscription is needed.\n\nOnly the subagent session ID is required. Omitted patch fields keep their\nprevious values; `null` clears them. Clearing capabilities disables child\nmutations. Clearing state leaves current activity unset/unconfirmed: it does\nnot imply idle, stop work, or create an `unknown` state snapshot. A concrete\nstate replaces the entire previous state object, not the session.\nThe title and description provide the parent's display metadata for the\nchild. They do not replace the content of individual messages or operations.", + "type": "object", + "properties": { + "sessionId": { + "description": "The opaque session ID identifying the child in all ACP messages.\n\nNested inside `update`; the enclosing notification's `sessionId` identifies\nthe immediate parent, not this child.", + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ] + }, + "title": { + "description": "The parent's human-readable display title for this child. It need not be unique.\n\nOmitted means unchanged; `null` clears it. If unset, the Client chooses\na fallback presentation.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "description": { + "description": "The parent's human-readable description of the child's role or purpose.\n\nOmitted means unchanged; `null` clears it. If unset, the Client chooses\na fallback presentation. This is current display metadata, not the\nhistory of instructions sent to the child.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "capabilities": { + "description": "Client-initiated session mutations permitted for this subagent session.\n\nOmitted means unchanged; `null` clears the capability set and disables\nchild mutations. If never supplied, no session mutations are permitted.\nRead-only operations retain their normal protocol semantics and capability\nrequirements. A concrete object replaces the whole capability set.", + "anyOf": [ + { + "$ref": "#/$defs/SubagentSessionCapabilities" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "state": { + "description": "Current state snapshot for the child session.\n\nOmitted means unchanged; `null` clears the current activity without\nasserting idle or sending an `unknown` snapshot. A concrete state\nreplaces the previous state object wholesale. If never supplied, the\ncurrent activity is unset/unconfirmed.", + "anyOf": [ + { + "$ref": "#/$defs/StateUpdate" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)\nOmitted means unchanged; `null` removes the metadata.", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["sessionId"] + }, + "SessionMessage": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nAn upsert of an inter-session message in the enclosing transcript.\n`messageId` is local to that transcript; separate views may use independent\nIDs. Endpoints are optional identity metadata, not content patches: omitted\nor `null` retains a known endpoint. Agents SHOULD supply available endpoints\non the first event; later events may omit them or enrich missing endpoints.\nSupplied endpoints must agree with the enclosing transcript. Live IDs refer\nto known sessions; history may retain unavailable counterparts. Missing\nidentities permit generic inter-session UI, not guessed participants or\nhuman authorship.\nOmitted `content` and `_meta` leave their stored values unchanged; `null`\nclears them. A concrete `content` array replaces existing content\n(`[]` also clears it); later chunks append. This neither changes session\nownership nor instructs the Client to deliver content.", + "type": "object", + "properties": { + "messageId": { + "description": "Identifier of this message within the enclosing session's transcript.", + "allOf": [ + { + "$ref": "#/$defs/MessageId" + } + ] + }, + "senderSessionId": { + "description": "Optional sending session identity; omission or `null` retains a known value.", + "anyOf": [ + { + "$ref": "#/$defs/SessionId" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "recipientSessionId": { + "description": "Optional receiving session identity; omission or `null` retains a known value.", + "anyOf": [ + { + "$ref": "#/$defs/SessionId" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "content": { + "description": "Omitted leaves content unchanged; `null` or `[]` clears it.\nA non-empty array replaces all content.", + "type": ["array", "null"], + "items": { + "$ref": "#/$defs/ContentBlock" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "_meta": { + "description": "Omitted leaves metadata unchanged; `null` removes it.\n\nImplementations MUST NOT make assumptions about values in `_meta`.", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["messageId"] + }, + "SessionMessageChunk": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA content block appended to a transcript-local message in arrival order.\nEndpoints are optional identity metadata, not content patches: omitted or\n`null` does not clear a known endpoint. Agents SHOULD supply available\nendpoints on the first event; later events may omit them or enrich missing\nendpoints. Supplied endpoints must agree with the enclosing transcript.\nLive IDs refer to known sessions; history may retain unavailable counterparts.\nMissing identities permit generic inter-session UI, not guessed participants\nor human authorship.\nChunk metadata applies only to that chunk. This does not instruct the Client\nto deliver content or imply that the recipient processed it.", + "type": "object", + "properties": { + "messageId": { + "description": "Identifier of this message within the enclosing session's transcript.", + "allOf": [ + { + "$ref": "#/$defs/MessageId" + } + ] + }, + "senderSessionId": { + "description": "Optional sending session identity; omission or `null` retains a known value.", + "anyOf": [ + { + "$ref": "#/$defs/SessionId" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "recipientSessionId": { + "description": "Optional receiving session identity; omission or `null` retains a known value.", + "anyOf": [ + { + "$ref": "#/$defs/SessionId" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "content": { + "description": "A single content block appended to the message.", + "allOf": [ + { + "$ref": "#/$defs/ContentBlock" + } + ] + }, + "_meta": { + "description": "Optional and nullable chunk-scoped metadata; omitted or `null` means none.\n\nImplementations MUST NOT make assumptions about values in `_meta`.", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["messageId", "content"] + }, "CompleteElicitationNotification": { "description": "Notification sent by the agent when a URL-based elicitation is complete.", "type": "object", @@ -6368,6 +6812,18 @@ ], "x-deserialize-default-on-error": true }, + "subagents": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nWhether the client understands exposed subagent sessions.\n\nOptional and nullable. Omitted or `null` both mean the client does not\nadvertise support.\nSupplying `{}` means the client understands child associations, work-state\nsnapshots, session-directed messages, and restricted-session semantics.", + "anyOf": [ + { + "$ref": "#/$defs/SubagentCapabilities" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, "plan": { "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nWhether the client supports `plan_update` and `plan_removed` session updates.\n\nOptional. Omitted or `null` both mean the client does not advertise support.\nSupplying `{}` means the client can receive both update types.", "anyOf": [ @@ -6549,6 +7005,18 @@ "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nClient support for presenting live advisory notices to the user.", "type": "object" }, + "SubagentCapabilities": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nCapability marker for exposing reusable child sessions as restricted ACP sessions.\n\nSupplying `{}` advertises support for child association and state updates,\nsession-directed messages, and restricted-session semantics. The client\nmust advertise this capability before the agent sends subagent updates or\nsession-directed messages.", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, "PlanCapabilities": { "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nCapabilities for receiving `plan_update` and `plan_removed` session updates.", "type": "object", diff --git a/schema/v2/schema.unstable.json b/schema/v2/schema.unstable.json index 03c0e762e..9a46b52ec 100644 --- a/schema/v2/schema.unstable.json +++ b/schema/v2/schema.unstable.json @@ -6051,6 +6051,54 @@ } ] }, + { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nAnnounces a child session created and owned by this session, or updates\nthat ownership association's metadata.", + "type": "object", + "properties": { + "sessionUpdate": { + "type": "string", + "const": "subagent_update" + } + }, + "required": ["sessionUpdate"], + "allOf": [ + { + "$ref": "#/$defs/SubagentUpdate" + } + ] + }, + { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA message upsert observed in this session's transcript, sent to or\nreceived from another session.", + "type": "object", + "properties": { + "sessionUpdate": { + "type": "string", + "const": "session_message" + } + }, + "required": ["sessionUpdate"], + "allOf": [ + { + "$ref": "#/$defs/SessionMessage" + } + ] + }, + { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nOne content block appended to a sent or received session message.", + "type": "object", + "properties": { + "sessionUpdate": { + "type": "string", + "const": "session_message_chunk" + } + }, + "required": ["sessionUpdate"], + "allOf": [ + { + "$ref": "#/$defs/SessionMessageChunk" + } + ] + }, { "title": "other", "description": "Custom or future session update.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants.\n\nReceivers that do not understand this update type should preserve the\nraw payload when storing, replaying, proxying, or forwarding session\nhistory, and otherwise ignore it or display it generically.", @@ -6263,6 +6311,36 @@ } }, "required": ["sessionUpdate"] + }, + { + "type": "object", + "properties": { + "sessionUpdate": { + "type": "string", + "const": "subagent_update" + } + }, + "required": ["sessionUpdate"] + }, + { + "type": "object", + "properties": { + "sessionUpdate": { + "type": "string", + "const": "session_message" + } + }, + "required": ["sessionUpdate"] + }, + { + "type": "object", + "properties": { + "sessionUpdate": { + "type": "string", + "const": "session_message_chunk" + } + }, + "required": ["sessionUpdate"] } ] }, @@ -6536,6 +6614,18 @@ } } }, + "UnknownStateUpdate": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nThe Agent cannot currently determine foreground activity.\n\nReport this when activity becomes unobservable, not merely because the session\nhas been quiet. The Client MUST stop presenting the previous state as confirmed\ncurrent activity, but may retain it as last known. A later state replaces this\nsnapshot normally.\n\nThis is not a task outcome or session closure. It does not cancel work, resolve\npending requests, or revoke capabilities; capabilities are updated separately.", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP for additional metadata.\nImplementations MUST NOT make assumptions about values at these keys.\nOptional; omitted and `null` mean no metadata for this state snapshot.", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, "StateUpdate": { "description": "The state of the agent's foreground work has changed.\n\nBackground activity can continue and emit other `session/update` notifications\nwhile `idle`. Those notifications do not change this state.", "anyOf": [ @@ -6587,6 +6677,22 @@ } ] }, + { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nThe Agent cannot currently determine foreground activity.\nThis replaces previously confirmed activity without ending the work or session.", + "type": "object", + "properties": { + "state": { + "type": "string", + "const": "unknown" + } + }, + "required": ["state"], + "allOf": [ + { + "$ref": "#/$defs/UnknownStateUpdate" + } + ] + }, { "title": "other", "description": "Custom or future session state.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants.", @@ -6629,6 +6735,16 @@ } }, "required": ["state"] + }, + { + "type": "object", + "properties": { + "state": { + "type": "string", + "const": "unknown" + } + }, + "required": ["state"] } ] }, @@ -7505,6 +7621,204 @@ }, "required": ["compactionId", "content"] }, + "SubagentSessionCapabilities": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nClient-initiated session mutations permitted for a specific subagent session.\n\nA mutation requires an explicit per-child capability; support for the method\non ordinary sessions does not grant support on a child.", + "type": "object", + "properties": { + "cancel": { + "description": "Permits the client to cancel this child's current work without ending\nthe session. Omitted or `null` means unsupported; an object (including\n`{}`) means supported.", + "anyOf": [ + { + "$ref": "#/$defs/SessionCancelCapabilities" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "SessionCancelCapabilities": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nCapability to cancel work in a subagent session without ending that session.\n\nSupplying `{}` advertises support; an omitted or `null` `cancel` does not.", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/draft/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "SubagentUpdate": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nNotification that the enclosing parent session created and owns a child session.\n\nLater updates modify the existing association's metadata, not its ownership.\n\nSent on the immediate parent session. The first update for an unknown\n[`SubagentUpdate::session_id`] announces the child and MUST be sent\nbefore any live request or notification bearing the child's session ID,\nincluding messages naming it as sender or recipient. Child events are\ndelivered automatically; no separate child load, resume, or subscription is needed.\nUnderstanding this update, registering child sessions, and applying their\noperation restrictions are baseline v2 requirements; no Client capability\nis required.\n\nOnly [`SubagentUpdate::session_id`] is required. Other fields have\npatch semantics: omitted fields leave the stored value unchanged, `null`\nclears or unsets the value, and concrete values replace it. For `state`,\n`null` removes the current state report and leaves activity unconfirmed;\nit does not report idle or cancellation. A child whose capabilities are\nunset permits no Client-initiated session mutations.\n\nThe title and description provide the parent's display metadata for the\nchild. Agents SHOULD mirror known child state changes in the parent's\n[`SubagentUpdate::state`] using the same [`StateUpdate`] snapshot as the\nordinary `state_update` notification on the child session. This reports\nchild state, not a separate parent-owned lifecycle. Consumers should\ntreat duplicate reports idempotently.\nCompleting or cancelling work does not end the association: the parent may\nmessage the same child again. Individual messages and their outcomes do not\nchange this association.", + "type": "object", + "properties": { + "sessionId": { + "description": "The opaque session ID identifying the child in all ACP messages.\n\nNested inside `update`; the enclosing notification's `sessionId` identifies\nthe immediate parent, not this child.", + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ] + }, + "title": { + "description": "The parent's human-readable display title for this child. It need not be unique.\n\nOptional and nullable. Omitted means unchanged; `null` clears it. If no\ntitle is set, the Client chooses a fallback presentation.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "description": { + "description": "The parent's human-readable description of the child's role or purpose.\n\nOptional and nullable. Omitted means unchanged; `null` clears it. This is\ncurrent display metadata, not the history of instructions sent to the child.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "capabilities": { + "description": "Client-initiated session mutations permitted for this subagent session.\n\nRead-only operations retain their normal protocol semantics and\ncapability requirements.", + "anyOf": [ + { + "$ref": "#/$defs/SubagentSessionCapabilities" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "state": { + "description": "The child's current foreground state, mirrored onto its parent association.\n\nOptional and nullable. Omitted means unchanged; `null` removes the\ncurrent report and leaves activity unconfirmed (not idle or cancelled).\nA concrete [`StateUpdate`] replaces the entire previous snapshot.", + "anyOf": [ + { + "$ref": "#/$defs/StateUpdate" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Omitted means no metadata update; `null` is an\nexplicit clear signal. Implementations MUST NOT make assumptions about values at these keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/draft/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["sessionId"] + }, + "SessionMessage": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nAn upsert of an inter-session message in the enclosing transcript.\n`messageId` is local to that transcript; separate views may use independent\nIDs. Endpoints are optional identity metadata, not content patches: omitted\nor `null` retains a known endpoint. Agents SHOULD supply available endpoints\non the first event; later events may omit them or enrich missing endpoints.\nSupplied endpoints must agree with the enclosing transcript. Live IDs refer\nto known sessions; history may retain unavailable counterparts. Missing\nidentities permit generic inter-session UI, not guessed participants or\nhuman authorship.\nA concrete `content` array replaces existing content; later chunks append.\nThis neither changes session ownership nor instructs the Client to deliver content.", + "type": "object", + "properties": { + "messageId": { + "description": "Identifier of this message within the enclosing session's transcript.", + "allOf": [ + { + "$ref": "#/$defs/MessageId" + } + ] + }, + "senderSessionId": { + "description": "Optional sending session identity; omission or `null` retains a known value.", + "anyOf": [ + { + "$ref": "#/$defs/SessionId" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "recipientSessionId": { + "description": "Optional receiving session identity; omission or `null` retains a known value.", + "anyOf": [ + { + "$ref": "#/$defs/SessionId" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "content": { + "description": "Omitted leaves content unchanged; `null` clears it; a concrete array\nreplaces the whole content collection.", + "type": ["array", "null"], + "items": { + "$ref": "#/$defs/ContentBlock" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "_meta": { + "description": "Omitted leaves metadata unchanged; `null` clears it.\n\nImplementations MUST NOT make assumptions about values in `_meta`.", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["messageId"] + }, + "SessionMessageChunk": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA content block appended to a transcript-local message in arrival order.\nEndpoints are optional identity metadata, not content patches: omitted or\n`null` does not clear a known endpoint. Agents SHOULD supply available\nendpoints on the first event; later events may omit them or enrich missing\nendpoints. Supplied endpoints must agree with the enclosing transcript.\nLive IDs refer to known sessions; history may retain unavailable counterparts.\nMissing identities permit generic inter-session UI, not guessed participants\nor human authorship.\nChunk metadata applies only to that chunk. This does not instruct the Client\nto deliver content or imply that the recipient processed it.", + "type": "object", + "properties": { + "messageId": { + "description": "Identifier of this message within the enclosing session's transcript.", + "allOf": [ + { + "$ref": "#/$defs/MessageId" + } + ] + }, + "senderSessionId": { + "description": "Optional sending session identity; omission or `null` retains a known value.", + "anyOf": [ + { + "$ref": "#/$defs/SessionId" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "recipientSessionId": { + "description": "Optional receiving session identity; omission or `null` retains a known value.", + "anyOf": [ + { + "$ref": "#/$defs/SessionId" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "content": { + "description": "A single content block appended to the message.", + "allOf": [ + { + "$ref": "#/$defs/ContentBlock" + } + ] + }, + "_meta": { + "description": "Optional and nullable chunk-scoped metadata; omitted or `null` means none.\n\nImplementations MUST NOT make assumptions about values in `_meta`.", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["messageId", "content"] + }, "CompleteElicitationNotification": { "description": "Notification sent by the agent when a URL-based elicitation is complete.", "type": "object",