From 66e5891f78a3469cbb727bf715ed7f5653a5e8ee Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Sat, 14 Nov 2026 15:38:07 +0100 Subject: [PATCH 1/2] perf(acp): store connect_with and with_spawned closures once The connection driver moved the caller's closure through five nested futures and reserved room for it in each. The `connect_with` future therefore grew by five times whatever the closure captured. A closure that captured a v2 `initialize` request and response made it large enough for `clippy::large_futures` to ask callers to box it. Box the `connect_with` closure and its future, and the `with_spawned` task. The connection future now holds captured state at most once. This costs two small allocations per connection. - tests/jsonrpc_connection_builder.rs checks that the future no longer multiplies captured state. - The conductor's v2 initialization tests drop the `Box::pin` wrappers they needed around `connect_with`. --- .../tests/initialization_v2.rs | 224 ++++++++---------- src/agent-client-protocol/src/jsonrpc.rs | 12 +- .../tests/jsonrpc_connection_builder.rs | 57 +++++ 3 files changed, 171 insertions(+), 122 deletions(-) diff --git a/src/agent-client-protocol-conductor/tests/initialization_v2.rs b/src/agent-client-protocol-conductor/tests/initialization_v2.rs index 847025d3..a4ae1937 100644 --- a/src/agent-client-protocol-conductor/tests/initialization_v2.rs +++ b/src/agent-client-protocol-conductor/tests/initialization_v2.rs @@ -212,22 +212,19 @@ async fn run_with_conductor( let (conductor_out, editor_in) = duplex(4096); let transport = ByteStreams::new(editor_out.compat_write(), editor_in.compat()); - // Boxed: the v2 initialize types make this future large. - Box::pin( - Client - .v2() - .name("v2-editor") - .with_spawned(|_cx| async move { - ConductorImpl::new_agent("v2-conductor", components) - .run(ByteStreams::new( - conductor_out.compat_write(), - conductor_in.compat(), - )) - .await - }) - .connect_with(transport, editor_task), - ) - .await + Client + .v2() + .name("v2-editor") + .with_spawned(|_cx| async move { + ConductorImpl::new_agent("v2-conductor", components) + .run(ByteStreams::new( + conductor_out.compat_write(), + conductor_in.compat(), + )) + .await + }) + .connect_with(transport, editor_task) + .await } async fn run_raw_client_with_conductor( @@ -238,20 +235,18 @@ async fn run_raw_client_with_conductor( let (conductor_out, editor_in) = duplex(4096); let transport = ByteStreams::new(editor_out.compat_write(), editor_in.compat()); - Box::pin( - Builder::new(Client) - .name("raw-v2-editor") - .with_spawned(|_cx| async move { - ConductorImpl::new_agent("v2-conductor", components) - .run(ByteStreams::new( - conductor_out.compat_write(), - conductor_in.compat(), - )) - .await - }) - .connect_with(transport, editor_task), - ) - .await + Builder::new(Client) + .name("raw-v2-editor") + .with_spawned(|_cx| async move { + ConductorImpl::new_agent("v2-conductor", components) + .run(ByteStreams::new( + conductor_out.compat_write(), + conductor_in.compat(), + )) + .await + }) + .connect_with(transport, editor_task) + .await } async fn assert_invalid_final_agent_initialize_response( @@ -624,45 +619,41 @@ async fn v2_session_new_preserves_request_and_response_through_proxy() -> Result let (editor_out, conductor_in) = duplex(4096); let (conductor_out, editor_in) = duplex(4096); let transport = ByteStreams::new(editor_out.compat_write(), editor_in.compat()); - Box::pin( - Client - .v2() - .on_receive_notification( - async move |notification: UntypedMessage, _cx| { - update_tx - .unbounded_send(notification) - .map_err(Error::into_internal_error) - }, - agent_client_protocol::on_receive_notification!(), - ) - .name("v2-editor") - .with_spawned(|_cx| async move { - ConductorImpl::new_agent("v2-conductor", components) - .run(ByteStreams::new( - conductor_out.compat_write(), - conductor_in.compat(), - )) + Client + .v2() + .on_receive_notification( + async move |notification: UntypedMessage, _cx| { + update_tx + .unbounded_send(notification) + .map_err(Error::into_internal_error) + }, + agent_client_protocol::on_receive_notification!(), + ) + .name("v2-editor") + .with_spawned(|_cx| async move { + ConductorImpl::new_agent("v2-conductor", components) + .run(ByteStreams::new( + conductor_out.compat_write(), + conductor_in.compat(), + )) + .await + }) + .connect_with(transport, async move |cx| { + let received_initialize = cx.send_request(initialize_request).block_task().await?; + assert_eq!(received_initialize, initialize_response); + + let received_session = cx.send_request(session_request).block_task().await?; + assert_eq!(received_session, session_response); + + let received_update = + tokio::time::timeout(std::time::Duration::from_secs(2), update_rx.next()) .await - }) - .connect_with(transport, async move |cx| { - let received_initialize = cx.send_request(initialize_request).block_task().await?; - assert_eq!(received_initialize, initialize_response); - - let received_session = cx.send_request(session_request).block_task().await?; - assert_eq!(received_session, session_response); - - let received_update = - tokio::time::timeout(std::time::Duration::from_secs(2), update_rx.next()) - .await - .expect("post-session update should not hang") - .ok_or_else(|| { - Error::internal_error().data("session update channel closed") - })?; - assert_eq!(received_update, session_update); - Ok(()) - }), - ) - .await?; + .expect("post-session update should not hang") + .ok_or_else(|| Error::internal_error().data("session update channel closed"))?; + assert_eq!(received_update, session_update); + Ok(()) + }) + .await?; Ok(()) } @@ -729,60 +720,53 @@ async fn v2_proxy_session_helper_preserves_response_and_routes_later_updates() - let (editor_out, conductor_in) = duplex(4096); let (conductor_out, editor_in) = duplex(4096); let transport = ByteStreams::new(editor_out.compat_write(), editor_in.compat()); - Box::pin( - Client - .v2() - .on_receive_notification( - async move |update: v2::UpdateSessionNotification, _cx| { - update_tx - .unbounded_send(update) - .map_err(Error::into_internal_error) - }, - agent_client_protocol::on_receive_notification!(), - ) - .with_spawned(|_cx| async move { - ConductorImpl::new_agent("v2-conductor", ProxiesAndAgent::new(agent).proxy(proxy)) - .run(ByteStreams::new( - conductor_out.compat_write(), - conductor_in.compat(), - )) + Client + .v2() + .on_receive_notification( + async move |update: v2::UpdateSessionNotification, _cx| { + update_tx + .unbounded_send(update) + .map_err(Error::into_internal_error) + }, + agent_client_protocol::on_receive_notification!(), + ) + .with_spawned(|_cx| async move { + ConductorImpl::new_agent("v2-conductor", ProxiesAndAgent::new(agent).proxy(proxy)) + .run(ByteStreams::new( + conductor_out.compat_write(), + conductor_in.compat(), + )) + .await + }) + .connect_with(transport, async move |cx| { + cx.send_request(initialize_request).block_task().await?; + + let received = cx + .send_request(v2::NewSessionRequest::new("/v2-helper-session")) + .block_task() + .await?; + assert_eq!(received, session_response); + + let (callback_session_id, callback_response) = + tokio::time::timeout(std::time::Duration::from_secs(2), callback_rx.next()) .await - }) - .connect_with(transport, async move |cx| { - cx.send_request(initialize_request).block_task().await?; + .expect("proxy session callback should not hang") + .ok_or_else(|| Error::internal_error().data("proxy callback channel closed"))?; + assert_eq!(callback_session_id, session_id); + assert_eq!(callback_response, expected_callback_response); - let received = cx - .send_request(v2::NewSessionRequest::new("/v2-helper-session")) - .block_task() - .await?; - assert_eq!(received, session_response); - - let (callback_session_id, callback_response) = - tokio::time::timeout(std::time::Duration::from_secs(2), callback_rx.next()) - .await - .expect("proxy session callback should not hang") - .ok_or_else(|| { - Error::internal_error().data("proxy callback channel closed") - })?; - assert_eq!(callback_session_id, session_id); - assert_eq!(callback_response, expected_callback_response); - - let update = - tokio::time::timeout(std::time::Duration::from_secs(2), update_rx.next()) - .await - .expect("post-response session update should not hang") - .ok_or_else(|| { - Error::internal_error().data("session update channel closed") - })?; - assert_eq!(update.session_id, session_id); - assert!(matches!( - update.update, - v2::SessionUpdate::StateUpdate(v2::StateUpdate::Running(_)) - )); - Ok(()) - }), - ) - .await + let update = tokio::time::timeout(std::time::Duration::from_secs(2), update_rx.next()) + .await + .expect("post-response session update should not hang") + .ok_or_else(|| Error::internal_error().data("session update channel closed"))?; + assert_eq!(update.session_id, session_id); + assert!(matches!( + update.update, + v2::SessionUpdate::StateUpdate(v2::StateUpdate::Running(_)) + )); + Ok(()) + }) + .await } #[tokio::test] diff --git a/src/agent-client-protocol/src/jsonrpc.rs b/src/agent-client-protocol/src/jsonrpc.rs index 4264f79c..eae05d44 100644 --- a/src/agent-client-protocol/src/jsonrpc.rs +++ b/src/agent-client-protocol/src/jsonrpc.rs @@ -1165,7 +1165,9 @@ impl< Fut: Future> + Send, { let location = Location::caller(); - self.with_runner(SpawnedRun::<_, P>::new(location, task)) + // Boxed for the same reason as `main_fn` in `connect_with`: the driver + // would otherwise store the task's captured state once per layer. + self.with_runner(SpawnedRun::<_, P>::new(location, Box::new(task))) } /// Run a callback when the incoming transport reaches clean EOF. @@ -1723,9 +1725,15 @@ impl< transport: impl ConnectTo + 'static, main_fn: impl AsyncFnOnce(P::Connection) -> Result, ) -> Result { + // The driver moves `main_fn` through several nested futures, and each + // one reserves room for it. Box the closure and its future so that the + // returned future holds the caller's captured state once instead of + // once per layer. Otherwise a closure that captures a few kilobytes, + // such as an `initialize` request and response, makes it very large. + let main_fn = Box::new(main_fn); let (_, future) = self.into_connection_and_future(transport, false, async move |connection| { - main_fn(protocol::connection_from_raw::(connection)).await + Box::pin(main_fn(protocol::connection_from_raw::(connection))).await }); future.await } diff --git a/src/agent-client-protocol/tests/jsonrpc_connection_builder.rs b/src/agent-client-protocol/tests/jsonrpc_connection_builder.rs index 1328df7d..5c0329c0 100644 --- a/src/agent-client-protocol/tests/jsonrpc_connection_builder.rs +++ b/src/agent-client-protocol/tests/jsonrpc_connection_builder.rs @@ -742,3 +742,60 @@ async fn test_connection_builder_as_component() -> Result<(), agent_client_proto ) .await } + +// ============================================================================ +// Connection future size +// ============================================================================ + +/// The size of a `connect_with` future whose `main_fn` captures `N` bytes. +fn connect_with_future_size() -> usize { + let (transport, _peer) = agent_client_protocol::Channel::duplex(); + let captured = [0u8; N]; + let future = UntypedRole + .builder() + .connect_with(transport, async move |_cx| { + std::hint::black_box(&captured); + Ok(()) + }); + std::mem::size_of_val(&future) +} + +/// The size of a `connect_with` future whose `with_spawned` task captures `N` +/// bytes. +fn with_spawned_future_size() -> usize { + let (transport, _peer) = agent_client_protocol::Channel::duplex(); + let captured = [0u8; N]; + let future = UntypedRole + .builder() + .with_spawned(move |_cx| async move { + std::hint::black_box(&captured); + Ok(()) + }) + .connect_with(transport, async |_cx| Ok(())); + std::mem::size_of_val(&future) +} + +/// The driver moves the caller's closures through several nested futures. +/// They are boxed, so the connection future holds their captured state at +/// most once. Unboxed, it held one copy per layer: a closure that captured a +/// few kilobytes, such as an `initialize` request and response, made the +/// future large enough for `clippy::large_futures` to ask callers to box it. +#[test] +fn connection_future_does_not_multiply_captured_state() { + const CAPTURED: usize = 4096; + const SLACK: usize = 128; + + let grown = connect_with_future_size::(); + let base = connect_with_future_size::<0>(); + assert!( + grown <= base + CAPTURED + SLACK, + "`connect_with` stores its closure more than once: {base} -> {grown} bytes" + ); + + let grown = with_spawned_future_size::(); + let base = with_spawned_future_size::<0>(); + assert!( + grown <= base + SLACK, + "`with_spawned` stores its task inline: {base} -> {grown} bytes" + ); +} From b1c4f473d400f0e6707a074838af6842d89dd9f1 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Sat, 14 Nov 2026 15:38:16 +0100 Subject: [PATCH 2/2] feat(acp): adopt schema 1.18.0 with skills, prompt suggestions and speed category Bump agent-client-protocol-schema to 1.18.0 (schemas v1.32.0 / v2.0.0-alpha.16). Add three features, each included in `unstable`: - `unstable_prompt_suggestions` forwards the `prompt_suggestions_update` session update and the client's `session.promptSuggestions` capability in v1 and v2. - `unstable_agent_skills` forwards `skill` command entries, `skill` content blocks and their capabilities. They exist in v2 only. - `unstable_speed_config_category` forwards the `speed` config option category. The schema bump also: - Adds the v2 `replay_complete` session update. Testy v2 and the `simple_agent_v2` example send it after a requested replay, also for an empty history, and never without `replayFrom`. The `v2_session_coordination` example uses it to tell replayed history from live updates. - Makes subagents part of v2 without `unstable_subagents`. - Adds `configOptions` on `session/fork` under `unstable_session_fork` and `unstable_session_options`. - Removes the `file` plan type under `unstable_plan_operations` and adds `path` on markdown plans. `InitializeOutcome` allows `clippy::large_enum_variant`, which the larger v2 `InitializeResponse` now triggers. - tests/schema_1_18.rs covers the new wire shapes, and the plan, subagent and Testy v2 tests follow the changes above. - md/protocol-v2.md gains a Schema 1.18 section with the new agent and client rules. It no longer says unknown command types may be shown as plain commands. md/features.md describes the new features. --- Cargo.lock | 4 +- Cargo.toml | 2 +- md/features.md | 36 +- md/protocol-v2-quickstart.md | 13 +- md/protocol-v2.md | 144 ++++- md/session-operation-coordination.md | 8 + md/testy.md | 4 +- .../src/testy/v2.rs | 22 +- .../tests/testy_v2.rs | 94 +++ src/agent-client-protocol/Cargo.toml | 6 + .../examples/simple_agent_v2.rs | 22 +- .../examples/v2_one_shot_client.rs | 6 +- .../examples/v2_session_coordination.rs | 22 +- .../examples/v2_session_coordination/tests.rs | 21 + .../src/jsonrpc/protocol_compat.rs | 4 + src/agent-client-protocol/src/session/v2.rs | 4 +- .../tests/schema_1_17.rs | 21 - .../tests/schema_1_18.rs | 583 ++++++++++++++++++ .../tests/schema_plan_operations.rs | 112 +++- .../tests/schema_subagents.rs | 9 +- 20 files changed, 1058 insertions(+), 79 deletions(-) create mode 100644 src/agent-client-protocol/tests/schema_1_18.rs diff --git a/Cargo.lock b/Cargo.lock index b39cb2db..64a9d9d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -144,9 +144,9 @@ dependencies = [ [[package]] name = "agent-client-protocol-schema" -version = "1.17.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fd595be39fc4f9c8170872e5d10d81b44cb6dca44bcc66daba0d34412d4237d" +checksum = "5128cd57704892e3978c85bfce0d532ef3162f585739f44726fa80d464c1782f" dependencies = [ "anyhow", "derive_more", diff --git a/Cargo.toml b/Cargo.toml index e2bdd3db..ec30d403 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,7 @@ agent-client-protocol-trace-viewer = { path = "src/agent-client-protocol-trace-v yopo = { package = "agent-client-protocol-yopo", path = "src/yopo" } # Protocol -agent-client-protocol-schema = { version = "=1.17.0", default-features = false } +agent-client-protocol-schema = { version = "=1.18.0", default-features = false } # Core async runtime tokio = { version = "1.52", default-features = false } diff --git a/md/features.md b/md/features.md index b2e68378..a5a6aced 100644 --- a/md/features.md +++ b/md/features.md @@ -122,12 +122,15 @@ surfaces. The former `unstable_session_notices` and `unstable_session_compaction SDK features have been removed. Applications still negotiate support through the corresponding protocol capabilities. -The `unstable_subagents` feature exposes the schema's subagent capabilities, -updates, and session-message types. It is also included in `unstable`. The -existing `session/update` notification route carries subagent updates in v1 and, -with `unstable_protocol_v2`, draft v2. This is schema support, not a subagent -scheduler or an implementation of application-level ownership and lifecycle -policy. +The `unstable_subagents` feature exposes the schema's v1 subagent capabilities, +updates, and session-message types. It is also included in `unstable`. Since +schema 1.18, draft v2 includes subagents with only `unstable_protocol_v2`: +`subagent_update`, `session_message`, `session_message_chunk`, the `unknown` +foreground state, `subagent` background work, and `session` tool call content +need no `unstable_subagents`. The existing `session/update` notification route +carries subagent updates in both versions. This is schema support, not a +subagent scheduler or an implementation of application-level ownership and +lifecycle policy. Client-provided system prompts are stable in schema 1.16 and available without feature flags in v1. Draft v2 only needs `unstable_protocol_v2`. The former @@ -161,6 +164,27 @@ you sent. Schema 1.17 also adds the `session` tool call content variant (`ToolCallContent::Session`) under `unstable_subagents`, and `description` on `AuthAccount` under `unstable_auth_status`. +Schema 1.18 adds three features, each also included in `unstable`: + +- `unstable_prompt_suggestions` exposes the `prompt_suggestions_update` session + update and the client's `session.promptSuggestions` capability in v1 and + draft v2. Agents only send the update to a client that advertises the + capability. A client must not send a suggestion as a prompt without an + explicit user action. +- `unstable_agent_skills` exposes Agent Skills in draft v2: + `AvailableCommand::Skill`, the `skill` content block (`ContentBlock::Skill`), + the agent's `session.prompt.skill` capability, and the client's + `session.commands.skill` capability. It adds nothing to v1. +- `unstable_speed_config_category` adds `SessionConfigOptionCategory::Speed` + (`speed`). Without it, `speed` parses as `Other("speed")`. + +Schema 1.18 also adds `configOptions` on `session/fork`, which needs both +`unstable_session_fork` and `unstable_session_options`. In draft v2, send it +through `fork_session_from`. With `unstable_plan_operations`, the `file` plan +type is removed: `PlanFile` and `PlanUpdateContent::file` no longer exist. Name +the file that holds a markdown plan with `PlanMarkdown::path` instead. See +[Schema 1.18](./protocol-v2.md#schema-118) for the draft v2 changes. + The schema's `unstable_proxy_chains` feature is not an SDK feature: the SDK always enables it, because the `Proxy` and `Conductor` roles and the conductor crate use its `proxy/initialize` and `proxy/successor` types. diff --git a/md/protocol-v2-quickstart.md b/md/protocol-v2-quickstart.md index 1f906a79..e2e09419 100644 --- a/md/protocol-v2-quickstart.md +++ b/md/protocol-v2-quickstart.md @@ -92,12 +92,13 @@ the connection can continue dispatching cancellation and other traffic. That task sends the accepted user message, a running update, output, and finally an idle update with a stop reason. -The example keeps history in memory and supports replay from the start. Every -successful resume sends a current idle-state snapshot after any replay and -before the response, even without replay. A production agent should replace -this with durable session storage, synchronize its actual foreground state, -define its supported replay cursors, and make resource cleanup and -cancellation robust across process failure. +The example keeps history in memory and supports replay from the start. It +ends a requested replay with a `replay_complete` update, also when the history +is empty. Every successful resume sends a current idle-state snapshot after +any replay and before the response, even without replay. A production agent +should replace this with durable session storage, synchronize its actual +foreground state, define its supported replay cursors, and make resource +cleanup and cancellation robust across process failure. For the connection APIs, proxy routing, and compatibility details surrounding these examples, continue with [Protocol V2](./protocol-v2.md). diff --git a/md/protocol-v2.md b/md/protocol-v2.md index 0a551596..511d72fc 100644 --- a/md/protocol-v2.md +++ b/md/protocol-v2.md @@ -235,9 +235,10 @@ Install connection handlers before `session/new`, `session/resume`, and feature-gated `session/fork` requests. This is especially important before calling `start_session` on a `v2::ResumeSessionBuilder`: replay updates precede the resume response on the wire, so preinstalled typed handlers observe them in -order. If a handler forwards updates to another task, the application is -responsible for any additional projection-drained barrier it needs before -treating replay as locally applied. +order. The agent ends the replayed history with a `replay_complete` update +(see [Schema 1.18](#schema-118)). If a handler forwards updates to another +task, the application is responsible for any additional projection-drained +barrier it needs before treating replay as locally applied. See [Ordered Application Dispatch](./ordered-application-dispatch.md) for a single-consumer queue that carries updates, response results, and closure. For an application-owned prototype that additionally coordinates shared resume @@ -660,7 +661,7 @@ The reuse probe is conservative: if parsing and serializing the raw v2 request would change any parameter, reuse is disabled and fallback opens a fresh connection. That does not turn an otherwise valid v2 request into an error. -## Schema changes in schema 1.5 through 1.14 +## Schema changes in schema 1.5 through 1.18 The `unstable_protocol_v2` API follows the moving draft schema. Schema 1.5 added semantic newtypes for paths, media types, IDs, and cursors, renamed @@ -732,8 +733,9 @@ Foreground states are aggregate snapshots, not per-prompt events: - New sessions default to idle, but updates can precede the new-session response. Do not overwrite them with idle when that response arrives. - Every successful resume must send a current-state snapshot after any replay - and before its response, even without a replay cursor. Do not replay - historical states as live activity or reset state to idle on the response. + and its `replay_complete`, and before its response, even without a replay + cursor. Do not replay historical states as live activity or reset state to + idle on the response. Testy and the echo example only resume sessions without foreground work, so their snapshot is idle without a new stop reason. - A resume should also return the current `configOptions` in its response and @@ -832,7 +834,9 @@ Draft v2 type changes: - `AvailableCommand` is a `type`-tagged union. Build commands with `AvailableCommand::Command(Command::new(..))` and read them through `name()`, `description()`, and `meta()`. Unknown command kinds parse as - `Other`; show them as plain commands by name and description, or ignore them. + `Other`. Ignore them, and do not show them as slash commands. Schema 1.13 + allowed showing them as plain commands. Schema 1.18 forbids it, because each + command type defines how its entries are invoked. - `ToolCallLocation.line` is a 1-based `NonZeroU32`. The builder still takes a `u32` and treats `0` as no line. - `TerminalExitStatus.exit_code` is an `i64` holding the code the platform @@ -899,9 +903,9 @@ Draft v2 type changes: empty after skipping values they cannot parse. - `SessionInfo.additionalDirectories` treats `null` as `[]`, and `Annotations.lastModified` accepts any ISO 8601 string. -- With `unstable_subagents`, subagent updates gain `cwd` and - `additional_directories` patch fields (absolute paths; omitted leaves them - unchanged, `null` inherits the parent's). +- Subagent updates gain `cwd` and `additional_directories` patch fields + (absolute paths; omitted leaves them unchanged, `null` inherits the + parent's). They needed `unstable_subagents` until schema 1.18. Connection and lifecycle rules: @@ -952,7 +956,127 @@ Client projections should also follow these rules: agents finalize unfinished tool calls when foreground work ends. - A prompt invokes a slash command only when its first text block starts with `/` and the command name followed by whitespace or the end of the block. + This applies to `command` entries only. - Show a placeholder for content you cannot render rather than dropping it. - A terminal's `size` is a rendering hint. - `_meta` keys `traceparent`, `tracestate`, and `baggage` use the W3C formats; extensions use reverse-DNS prefixes such as `com.example/requestId`. + +### Schema 1.18 + +Draft v2 type changes: + +- `SessionUpdate::ReplayComplete` marks the end of the history replay that a + `session/resume` request asked for. `ReplayComplete` has only an optional + `_meta`. +- Subagents are part of v2 and no longer need `unstable_subagents`: + `SessionUpdate::SubagentUpdate`, `SessionUpdate::SessionMessage`, + `SessionUpdate::SessionMessageChunk`, `StateUpdate::Unknown`, + `BackgroundWork::Subagent`, and `ToolCallContent::Session` are always + available. These wire values used to parse as `Other` without the feature + and now parse as their typed variants, so add match arms where you relied on + the fallback. v1 still needs `unstable_subagents`. +- With `unstable_plan_operations`, the `file` plan type is removed: + `PlanUpdateContent::File`, `PlanFile`, and `PlanUpdateContent::file` no + longer exist, and a received `file` plan parses as + `PlanUpdateContent::Other`. `PlanMarkdown` gains an optional `path`, the + absolute path of the file that holds the plan. Build it with + `PlanUpdateContent::Markdown(PlanMarkdown::new(..).path(..))`. +- With `unstable_agent_skills`, `AvailableCommand::Skill` advertises an Agent + Skill and `ContentBlock::Skill` (`SkillReference`) invokes one in a prompt. + The agent advertises `PromptCapabilities::skill`, and the client advertises + `session.commands.skill`. +- With `unstable_prompt_suggestions`, + `SessionUpdate::PromptSuggestionsUpdate` replaces the session's list of + prompts the agent predicts the user will send next. The client advertises + `session.promptSuggestions`. +- `ClientCapabilities::session` (`ClientSessionCapabilities`) exists with + either of those two features. It only groups capabilities: `{}` advertises + nothing. +- With `unstable_speed_config_category`, `SessionConfigOptionCategory::Speed` + marks a response speed option such as a fast mode toggle. +- With `unstable_session_fork` and `unstable_session_options`, + `ForkSessionRequest::config_options` carries initial config option values. + The entries apply on top of the configuration the fork would start with. + +Agents must follow these replay rules: + +- For a `session/resume` request with a `replayFrom` cursor, send + `replay_complete` exactly once on the session, after the last replayed + update and before current-state synchronization and the response. Send it + for an empty history too. +- Do not send it when `replayFrom` is omitted or `null`, for `session/new`, + when the cursor is rejected with `-32602`, or when the replay cannot finish. +- Every update for the session between the request and `replay_complete` must + be replayed history. Hold everything else until after it: chunks of a + message that is still streaming, notices, `available_commands_update`, + `config_option_update`, and the current session info and state snapshots. + Permission requests and elicitations should wait too. +- Each child session announced with `subagent_update` during the resume gets + its own `replay_complete` on its own `sessionId`, after its last replayed + update. The markers of different sessions have no required order. +- `replay_complete` does not make the session active. A session that was not + active before the request becomes active with the successful response, so + `session/prompt` fails with `-32002` until then. +- Report known gaps in the retained history after `replay_complete`, for + example with a notice. + +Testy and `simple_agent_v2` follow these rules. + +Other agent rules: + +- `auth/logout` is idempotent. Respond with the same empty result when there + is no authenticated state to end. +- Answer a repeated `tool_call/cancel` or `terminal/cancel` with `-32602` once + the tool call is final or the terminal has exited. The `tool_call_update` or + `terminal_update` reports the outcome. +- List the values of a `select` `thought_level` option from lowest to highest, + and of a `select` `speed` option from slowest to fastest. In a `boolean` + `speed` option, `true` is the faster setting. +- `content` on `tool_call_update` replaces the whole array, so send the + `session` references again when you send new content. Add a reference only + for a handle you can resolve to a known session. +- Omit `senderSessionId` on a session-directed message when you have no + evidence of who sent it. Do not assume the parent. +- Report `unknown` on an actual loss of observability, not after silence. + Leave unfinished tool calls and terminals as last reported, and update + capabilities explicitly when a control is no longer available. +- Send `skill` command entries only to a client that advertises + `session.commands.skill`, and advertise `session.prompt.skill` when you do. + Reject a prompt with a `skill` block that your capability does not allow, or + whose `name` you cannot resolve, with `-32602` and without inserting it. +- Send `prompt_suggestions_update` only to a client that advertises + `session.promptSuggestions`. When you accept a `session/prompt`, replace a + non-empty list or withdraw it with an empty one. Do not replay suggestions. +- While applying initial `configOptions`, do not send `config_option_update` + for them on the requesting connection, and send other updates they cause + only after the `session/new` or `session/fork` response. A resumed session + that is active on other connections does get `config_option_update` there. +- `session/options` may start the agent's runtime to resolve the options. It + still stores no session and writes no history. + +Client projections should also follow these rules: + +- Use `replay_complete` to find the end of the replayed history. Every update + for the session before it is history, and every update after it is not. + A resume that ends in an error response or a closed connection without + `replay_complete` delivered a truncated replay. Do not use those updates as + the full history. A successful response without `replay_complete` is a + protocol violation, but the response still certifies the history. +- Ignore command types you do not understand, and never show them as slash + commands. The `/name` syntax invokes `command` entries only. Invoke a skill + with a `skill` content block, and only where the agent's + `session.prompt.skill` capability allows it: one leading block, or any + number at any position with `inline`. +- On `unknown`, stop presenting a previous `running` or `requires_action` as + confirmed live activity. It is not `idle`, not an outcome, and not a + cancellation. A later known state replaces it. +- Session-directed messages are agent content. Attribute them to the sending + session and do not present them as user messages. +- Render a markdown plan from `content`. Use `path` only to open the file, and + do not read a `path` outside the session's roots without user action. +- Skip a whole prompt suggestion you cannot use rather than dropping one of + its blocks. Never send a suggestion as a prompt without an explicit user + action. +- Render `speed` options near the model selector. +- Do not call `session/options` on a timer. diff --git a/md/session-operation-coordination.md b/md/session-operation-coordination.md index 1a1fd2fd..d1a3b4e9 100644 --- a/md/session-operation-coordination.md +++ b/md/session-operation-coordination.md @@ -60,6 +60,14 @@ does not expose partial replay to a loading view. Production applications should project protocol entities according to their own UI needs, outside SDK callbacks. +The agent ends the replayed history with a `replay_complete` update. The +projection records how many leading updates precede it in `history_len`. The +marker, current-state synchronization, and later updates are live. A resume +that fails is never exposed, so a truncated replay never reaches a view. An +agent that responds successfully without `replay_complete` violates the +protocol. The response still certifies the updates before it, so the example +counts them all as history. + Do not generalize the next `idle` state into completion of a particular prompt: v2 state is session-wide, updates have no prompt identity, and background updates may continue while idle. diff --git a/md/testy.md b/md/testy.md index 64fc7ffe..f2cf7d12 100644 --- a/md/testy.md +++ b/md/testy.md @@ -118,7 +118,9 @@ The v2 implementation follows the split prompt lifecycle: `wait_for_cancel` makes this separation deterministic for client tests: prompt acceptance returns while work remains active, and `session/cancel` is confirmed by `idle` with the `cancelled` stop reason. Testy also keeps simple message history and replays it before a `session/resume` response -when the client requests replay from the start. Every successful resume sends +when the client requests replay from the start. It ends that replay with a +`replay_complete` update, also when the history is empty, and sends none when +replay was not requested. Every successful resume sends the current session info (`session_info_update` with the title) and a current idle-state snapshot after any replay and before responding, including when replay was not requested. Neither is a replayed historical change, and the diff --git a/src/agent-client-protocol-test/src/testy/v2.rs b/src/agent-client-protocol-test/src/testy/v2.rs index b33bd02a..74ab69cb 100644 --- a/src/agent-client-protocol-test/src/testy/v2.rs +++ b/src/agent-client-protocol-test/src/testy/v2.rs @@ -129,10 +129,12 @@ impl Testy { sessions } + /// Reattach to a session. Returns the history to replay when the client + /// sent a `replayFrom` cursor, and `None` when it asked for no replay. fn resume_session( &self, request: &acp::ResumeSessionRequest, - ) -> Result, agent_client_protocol::Error> { + ) -> Result>, agent_client_protocol::Error> { require_absolute(&request.cwd, "cwd")?; reject_mcp_servers(&request.mcp_servers)?; let mut state = self.lock_state(); @@ -154,8 +156,8 @@ impl Testy { } let replay = match &request.replay_from { - None => Vec::new(), - Some(acp::ReplayFrom::Start(_)) => session.history.clone(), + None => None, + Some(acp::ReplayFrom::Start(_)) => Some(session.history.clone()), Some(_) => { return Err(invalid_params("unsupported session replay cursor")); } @@ -576,8 +578,18 @@ impl ConnectTo for Testy { Ok(replay) => replay, Err(error) => return responder.respond_with_error(error), }; - for update in replay { - send_update(&connection, &request.session_id, update)?; + // A resume with a `replayFrom` cursor ends its replay + // with `replay_complete`, also when the history is + // empty. A resume without a cursor must not send it. + if let Some(replay) = replay { + for update in replay { + send_update(&connection, &request.session_id, update)?; + } + send_update( + &connection, + &request.session_id, + acp::SessionUpdate::ReplayComplete(acp::ReplayComplete::new()), + )?; } // Synchronize current metadata rather than replaying // historical changes. diff --git a/src/agent-client-protocol-test/tests/testy_v2.rs b/src/agent-client-protocol-test/tests/testy_v2.rs index dd35044f..1b457f7d 100644 --- a/src/agent-client-protocol-test/tests/testy_v2.rs +++ b/src/agent-client-protocol-test/tests/testy_v2.rs @@ -218,6 +218,14 @@ async fn testy_router_runs_v2_prompt_lifecycle_and_resume_replay() { if message.message_id == agent_message_id && only_text(&message.content) == Some("hello from v2") )); + // `replay_complete` follows the last replayed update and precedes + // current-state synchronization and the response. + let replay_complete = next_update(&mut event_rx).await; + assert_eq!(replay_complete.session_id, session_id); + assert!(matches!( + replay_complete.update, + v2::SessionUpdate::ReplayComplete(_) + )); assert_session_info_snapshot(next_update(&mut event_rx).await); assert!(matches!( next_update(&mut event_rx).await.update, @@ -246,6 +254,8 @@ async fn testy_router_runs_v2_prompt_lifecycle_and_resume_replay() { .map_err(Error::into_internal_error) } })?; + // Without `replayFrom` there is no replay, so no `replay_complete`: + // the first update is the session info snapshot. assert_session_info_snapshot(next_update(&mut event_rx).await); assert!(matches!( next_update(&mut event_rx).await.update, @@ -270,6 +280,90 @@ async fn testy_router_runs_v2_prompt_lifecycle_and_resume_replay() { .expect("Testy v2 lifecycle failed"); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn testy_v2_marks_the_end_of_an_empty_replay() { + let (event_tx, mut event_rx) = unbounded_channel(); + let update_tx = event_tx.clone(); + let client = Client + .v2() + .on_receive_notification( + async move |update: v2::UpdateSessionNotification, + _connection: ConnectionTo| { + update_tx + .send(TestEvent::Update(Box::new(update))) + .map_err(Error::into_internal_error) + }, + agent_client_protocol::on_receive_notification!(), + ) + .connect_with(testy::v2::Testy::new(), async move |connection| { + connection + .send_request(v2::InitializeRequest::new( + ProtocolVersion::V2, + implementation(), + )) + .block_task() + .await?; + + let session_id = connection + .build_session(PathBuf::from("/tmp")) + .start_session() + .block_task() + .await? + .session + .session_id() + .clone(); + connection + .send_request(v2::CloseSessionRequest::new(session_id.clone())) + .block_task() + .await?; + + let resume = v2::ResumeSessionRequest::new(session_id.clone(), PathBuf::from("/tmp")) + .replay_from(v2::ReplayFrom::from(v2::ReplayFromStart::new())); + connection + .resume_session_from(resume) + .start_session() + .on_receiving_result({ + let event_tx = event_tx.clone(); + async move |result| { + result?; + event_tx + .send(TestEvent::ResumeResponded) + .map_err(Error::into_internal_error) + } + })?; + + // The session has no history. `replay_complete` is still sent, as + // the first update for the session after the request. + let replay_complete = next_update(&mut event_rx).await; + assert_eq!(replay_complete.session_id, session_id); + assert!(matches!( + replay_complete.update, + v2::SessionUpdate::ReplayComplete(_) + )); + assert_session_info_snapshot(next_update(&mut event_rx).await); + assert!(matches!( + next_update(&mut event_rx).await.update, + v2::SessionUpdate::StateUpdate(v2::StateUpdate::Idle(idle)) + if idle.stop_reason.is_none() + )); + assert!(matches!( + next_event(&mut event_rx).await, + TestEvent::ResumeResponded + )); + + connection + .send_request(v2::CloseSessionRequest::new(session_id)) + .block_task() + .await?; + Ok(()) + }); + + tokio::time::timeout(TIMEOUT, client) + .await + .expect("Testy v2 empty replay timed out") + .expect("Testy v2 empty replay failed"); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn testy_v2_prompt_acceptance_is_independent_from_cancellation_completion() { let (event_tx, mut event_rx) = unbounded_channel(); diff --git a/src/agent-client-protocol/Cargo.toml b/src/agent-client-protocol/Cargo.toml index a070daab..df6f5f68 100644 --- a/src/agent-client-protocol/Cargo.toml +++ b/src/agent-client-protocol/Cargo.toml @@ -50,28 +50,34 @@ stdio = ["dep:blocking"] # Forward unstable features from agent-client-protocol-schema. # Enable these to get support for the corresponding unstable ACP surfaces. unstable = [ + "unstable_agent_skills", "unstable_auth_status", "unstable_end_turn_token_usage", "unstable_llm_providers", "unstable_mcp_over_acp", "unstable_plan_operations", + "unstable_prompt_suggestions", "unstable_session_archive", "unstable_session_fork", "unstable_session_options", "unstable_session_set_title", "unstable_session_status", + "unstable_speed_config_category", "unstable_subagents", ] +unstable_agent_skills = ["agent-client-protocol-schema/unstable_agent_skills"] unstable_auth_status = ["agent-client-protocol-schema/unstable_auth_status"] unstable_end_turn_token_usage = ["agent-client-protocol-schema/unstable_end_turn_token_usage"] unstable_llm_providers = ["agent-client-protocol-schema/unstable_llm_providers"] unstable_mcp_over_acp = ["agent-client-protocol-schema/unstable_mcp_over_acp"] unstable_plan_operations = ["agent-client-protocol-schema/unstable_plan_operations"] +unstable_prompt_suggestions = ["agent-client-protocol-schema/unstable_prompt_suggestions"] unstable_session_archive = ["agent-client-protocol-schema/unstable_session_archive"] unstable_session_fork = ["agent-client-protocol-schema/unstable_session_fork"] unstable_session_options = ["agent-client-protocol-schema/unstable_session_options"] unstable_session_set_title = ["agent-client-protocol-schema/unstable_session_set_title"] unstable_session_status = ["agent-client-protocol-schema/unstable_session_status"] +unstable_speed_config_category = ["agent-client-protocol-schema/unstable_speed_config_category"] unstable_subagents = ["agent-client-protocol-schema/unstable_subagents"] unstable_protocol_v2 = ["agent-client-protocol-schema/unstable_protocol_v2"] diff --git a/src/agent-client-protocol/examples/simple_agent_v2.rs b/src/agent-client-protocol/examples/simple_agent_v2.rs index 444ba4d5..8df215a4 100644 --- a/src/agent-client-protocol/examples/simple_agent_v2.rs +++ b/src/agent-client-protocol/examples/simple_agent_v2.rs @@ -98,10 +98,12 @@ impl EchoAgent { sessions } + /// Reattach to a session. Returns the history to replay when the client + /// sent a `replayFrom` cursor, and `None` when it asked for no replay. fn resume_session( &self, request: &schema::ResumeSessionRequest, - ) -> Result> { + ) -> Result>> { require_absolute(&request.cwd)?; reject_mcp_servers(&request.mcp_servers)?; let mut state = self.state.lock().expect("session state lock poisoned"); @@ -123,8 +125,8 @@ impl EchoAgent { } let history = match &request.replay_from { - None => Vec::new(), - Some(schema::ReplayFrom::Start(_)) => session.history.clone(), + None => None, + Some(schema::ReplayFrom::Start(_)) => Some(session.history.clone()), Some(_) => return Err(invalid_params("unsupported replay cursor")), }; session.active = true; @@ -546,8 +548,18 @@ async fn main() -> Result<()> { Ok(history) => history, Err(error) => return responder.respond_with_error(error), }; - for update in history { - send_update(&connection, &request.session_id, update)?; + // A resume with a `replayFrom` cursor ends its replay with + // `replay_complete`, also when the history is empty. A + // resume without a cursor must not send it. + if let Some(history) = history { + for update in history { + send_update(&connection, &request.session_id, update)?; + } + send_update( + &connection, + &request.session_id, + schema::SessionUpdate::ReplayComplete(schema::ReplayComplete::new()), + )?; } // This example only resumes sessions without foreground work. // Every successful resume synchronizes current state, with or diff --git a/src/agent-client-protocol/examples/v2_one_shot_client.rs b/src/agent-client-protocol/examples/v2_one_shot_client.rs index ff9a7c46..22d231ef 100644 --- a/src/agent-client-protocol/examples/v2_one_shot_client.rs +++ b/src/agent-client-protocol/examples/v2_one_shot_client.rs @@ -114,9 +114,9 @@ async fn wait_until_idle( return Ok((projection.text(), idle.stop_reason)); } } - // Running, requires_action, and any state this client does not - // recognize all mean foreground work is still active. An unknown - // state must never be treated as idle. + // Running and requires_action mean foreground work is active. The + // `unknown` state and any state this client does not recognize + // are not idle either, so keep waiting for a known idle state. schema::SessionUpdate::StateUpdate(_) => observed_active = true, update if observed_active => projection.apply(update), _ => {} diff --git a/src/agent-client-protocol/examples/v2_session_coordination.rs b/src/agent-client-protocol/examples/v2_session_coordination.rs index ea82ff4a..ba431668 100644 --- a/src/agent-client-protocol/examples/v2_session_coordination.rs +++ b/src/agent-client-protocol/examples/v2_session_coordination.rs @@ -31,6 +31,10 @@ type Waiters = HashMap; struct Projection { response: schema::ResumeSessionResponse, updates: Vec, + /// How many leading `updates` are replayed history. The rest are live: + /// the `replay_complete` marker, current-state synchronization, and + /// everything received after the resume response. + history_len: usize, disconnected: bool, } @@ -283,9 +287,18 @@ impl Driver { } }; + let updates = replay.unwrap_or_default(); + // `replay_complete` ends the replayed history. An agent that + // omits it violates the protocol, but the successful response + // still certifies everything before it as the history. + let history_len = updates + .iter() + .position(|update| matches!(update, schema::SessionUpdate::ReplayComplete(_))) + .unwrap_or(updates.len()); let view = Rc::new(RefCell::new(Projection { response, - updates: replay.unwrap_or_default(), + updates, + history_len, disconnected: false, })); let mut leases = HashSet::new(); @@ -486,17 +499,14 @@ async fn main() -> Result<(), Box> { let first = sessions.load(id.clone())?; let second = sessions.load(id.clone())?; let (first, second) = futures::try_join!(first.wait(), second.wait())?; - println!( - "Shared replay: {} updates", - first.view.borrow().updates.len() - ); + println!("Shared replay: {} updates", first.view.borrow().history_len); println!("Resume setup: {:?}", first.view.borrow().response); drop((first, second)); let reopened = sessions.load(id)?.wait().await?; println!( "Fresh replay after close: {} updates", - reopened.view.borrow().updates.len() + reopened.view.borrow().history_len ); // The last application owner disappearing stops the transport rather // than waiting indefinitely for remote cleanup. diff --git a/src/agent-client-protocol/examples/v2_session_coordination/tests.rs b/src/agent-client-protocol/examples/v2_session_coordination/tests.rs index fc128773..9ea918f6 100644 --- a/src/agent-client-protocol/examples/v2_session_coordination/tests.rs +++ b/src/agent-client-protocol/examples/v2_session_coordination/tests.rs @@ -48,9 +48,11 @@ impl Peer { } } + /// Replay one update, mark the end of the replay, and respond. fn replay_and_respond(&self, id: RawJsonRpcId, session: &str, text: &str) { self.send_all([ update(session, text), + replay_complete(session), RawJsonRpcMessage::response(id, Ok(json!({}))), ]); } @@ -111,12 +113,25 @@ fn update(session: &str, text: &str) -> RawJsonRpcMessage { .unwrap() } +fn replay_complete(session: &str) -> RawJsonRpcMessage { + RawJsonRpcMessage::notification( + "session/update".into(), + json!({ + "sessionId": session, + "update": {"sessionUpdate": "replay_complete"} + }), + ) + .unwrap() +} + +/// The text of every chunk in the log, replayed or live. fn text(session: &Session) -> String { session .view .borrow() .updates .iter() + .filter(|update| !matches!(update, schema::SessionUpdate::ReplayComplete(_))) .map(|update| { let schema::SessionUpdate::AgentMessageChunk(chunk) = update else { panic!("expected a chunk"); @@ -178,6 +193,9 @@ async fn concurrent_loaders_share_one_resume_and_projection() { assert!(Rc::ptr_eq(&second.view, &third.view)); assert_eq!(text(&second), "hello"); assert_eq!(second.view.borrow().response, expected); + // This peer sends no `replay_complete`. The successful response + // still certifies the update before it as the history. + assert_eq!(second.view.borrow().history_len, 1); let clone = second.clone(); drop(second); @@ -258,6 +276,9 @@ async fn delayed_close_blocks_only_its_session() { let reopened = reopened.wait().await?; assert_eq!(text(&reopened), "fresh"); assert_eq!(text(&other), "other+live"); + // `replay_complete` separates the replayed chunk from the live one. + assert_eq!(other.view.borrow().history_len, 1); + assert_eq!(other.view.borrow().updates.len(), 3); drop(reopened); drop(other); finished.await.unwrap(); diff --git a/src/agent-client-protocol/src/jsonrpc/protocol_compat.rs b/src/agent-client-protocol/src/jsonrpc/protocol_compat.rs index 9b99e4b2..b2b526d7 100644 --- a/src/agent-client-protocol/src/jsonrpc/protocol_compat.rs +++ b/src/agent-client-protocol/src/jsonrpc/protocol_compat.rs @@ -164,6 +164,10 @@ pub(crate) fn normalize_v1_initialize_request( #[cfg(feature = "unstable_protocol_v2")] #[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] +#[allow( + clippy::large_enum_variant, + reason = "built once per connection; boxing the response would only make it harder to match on" +)] pub enum InitializeOutcome { /// The agent selected v2 and the connection is ready for v2 traffic. Initialized(crate::v2::schema::InitializeResponse), diff --git a/src/agent-client-protocol/src/session/v2.rs b/src/agent-client-protocol/src/session/v2.rs index 24e13a8e..825e32fa 100644 --- a/src/agent-client-protocol/src/session/v2.rs +++ b/src/agent-client-protocol/src/session/v2.rs @@ -249,7 +249,9 @@ where /// /// Register typed session update handlers before connecting. When the /// request asks for replay, the agent sends those updates before the - /// [`v2::schema::ResumeSessionResponse`](v2::ResumeSessionResponse). Call + /// [`v2::schema::ResumeSessionResponse`](v2::ResumeSessionResponse), and + /// ends the replayed history with a + /// [`v2::schema::ReplayComplete`](v2::ReplayComplete) update. Call /// [`ResumeSessionBuilder::start_session`] to publish the request. pub fn resume_session_from( &self, diff --git a/src/agent-client-protocol/tests/schema_1_17.rs b/src/agent-client-protocol/tests/schema_1_17.rs index d6eb7009..b8829239 100644 --- a/src/agent-client-protocol/tests/schema_1_17.rs +++ b/src/agent-client-protocol/tests/schema_1_17.rs @@ -173,27 +173,6 @@ mod v2_methods { } } - #[cfg(feature = "unstable_subagents")] - #[test] - fn v2_session_tool_call_content_round_trips() { - let mut params = json!({ - "sessionId": "parent", - "update": { - "sessionUpdate": "tool_call_update", - "toolCallId": "call_1", - "content": [{ "type": "session", "sessionId": "child" }] - } - }); - assert_message_round_trips::("session/update", ¶ms); - - // A malformed known variant is dropped like other malformed display - // items. It is not read as a custom variant. - params["update"]["content"] = json!([{ "type": "session" }]); - let parsed = v2::AgentNotification::parse_message("session/update", ¶ms).unwrap(); - let untyped = parsed.to_untyped_message().unwrap(); - assert_eq!(untyped.params()["update"]["content"], json!([])); - } - #[cfg(feature = "unstable_auth_status")] #[test] fn v2_auth_account_description_round_trips() { diff --git a/src/agent-client-protocol/tests/schema_1_18.rs b/src/agent-client-protocol/tests/schema_1_18.rs new file mode 100644 index 00000000..fea93a56 --- /dev/null +++ b/src/agent-client-protocol/tests/schema_1_18.rs @@ -0,0 +1,583 @@ +//! Schema 1.18 additions parse into the SDK's typed v1 and v2 messages. + +#![cfg(any( + feature = "unstable_agent_skills", + feature = "unstable_prompt_suggestions", + feature = "unstable_protocol_v2", + feature = "unstable_session_fork", + feature = "unstable_speed_config_category" +))] +#![allow(dead_code, unused_imports)] + +use agent_client_protocol::v1::schema as v1; +use agent_client_protocol::{JsonRpcMessage, JsonRpcResponse}; +use serde_json::{Value, json}; + +/// Parse `params` as `method` into `E`, check the parsed method, and check +/// that the message serializes back to the same params. +fn assert_message_round_trips(method: &str, params: &Value) -> E { + let parsed = E::parse_message(method, params) + .unwrap_or_else(|error| panic!("{method} should parse: {error:?}")); + assert_eq!(parsed.method(), method); + let untyped = parsed.to_untyped_message().unwrap(); + assert_eq!(untyped.method(), method); + assert_eq!(untyped.params(), params, "{method} params round trip"); + parsed +} + +/// Parse `result` as the response to `method` into `R` and check that it +/// serializes back to the same JSON. +fn assert_response_round_trips(method: &str, result: &Value) { + let parsed = R::from_json(method, result.clone()) + .unwrap_or_else(|error| panic!("{method} response should parse: {error:?}")); + assert_eq!( + &parsed.into_json(method).unwrap(), + result, + "{method} result" + ); +} + +/// Parsing `params` as `method` into `E` fails with `-32602`. +fn assert_invalid_params(method: &str, params: &Value) { + let error = E::parse_message(method, params) + .err() + .unwrap_or_else(|| panic!("{method} should reject {params}")); + assert_eq!(i32::from(error.code), -32602, "{method} error code"); +} + +/// A `session/update` notification's params for `update`. +fn session_update(update: Value) -> Value { + json!({ "sessionId": "sess_1", "update": update }) +} + +#[cfg(feature = "unstable_prompt_suggestions")] +#[test] +fn v1_prompt_suggestions_update_round_trips() { + let params = session_update(json!({ + "sessionUpdate": "prompt_suggestions_update", + "suggestions": [ + { "prompt": [{ "type": "text", "text": "Run the tests" }] }, + { + "prompt": [ + { "type": "text", "text": "Add tests for " }, + { "type": "resource_link", "name": "parser.rs", "uri": "file:///work/parser.rs" } + ], + "_meta": { "rank": 2 } + } + ] + })); + let parsed = assert_message_round_trips::("session/update", ¶ms); + let v1::AgentNotification::SessionNotification(notification) = parsed else { + panic!("expected a session notification"); + }; + let v1::SessionUpdate::PromptSuggestionsUpdate(update) = notification.update else { + panic!("expected a prompt suggestions update"); + }; + assert_eq!(update.suggestions.len(), 2); + + // An empty list withdraws the suggestions. + assert_message_round_trips::( + "session/update", + &session_update(json!({ + "sessionUpdate": "prompt_suggestions_update", + "suggestions": [] + })), + ); +} + +#[cfg(feature = "unstable_prompt_suggestions")] +#[test] +fn v1_prompt_suggestions_update_skips_suggestions_it_cannot_parse() { + // One bad block drops the whole suggestion. Dropping only the block would + // change what the prompt asks for. + let parsed = v1::AgentNotification::parse_message( + "session/update", + &session_update(json!({ + "sessionUpdate": "prompt_suggestions_update", + "suggestions": [ + { "prompt": [{ "type": "text", "text": "Keep" }] }, + { "prompt": [{ "type": "text", "text": "Open " }, { "type": "text" }] }, + { "text": "No prompt" }, + "not an object" + ] + })), + ) + .unwrap(); + assert_eq!( + parsed.to_untyped_message().unwrap().params()["update"]["suggestions"], + json!([{ "prompt": [{ "type": "text", "text": "Keep" }] }]) + ); + + // The list itself is required and non-null. + for update in [ + json!({ "sessionUpdate": "prompt_suggestions_update" }), + json!({ "sessionUpdate": "prompt_suggestions_update", "suggestions": null }), + ] { + assert_invalid_params::("session/update", &session_update(update)); + } +} + +#[cfg(feature = "unstable_prompt_suggestions")] +#[test] +fn v1_client_advertises_prompt_suggestions() { + for prompt_suggestions in [ + json!({}), + json!({ "image": {}, "audio": {}, "embeddedContext": {} }), + ] { + let params = json!({ + "protocolVersion": 1, + "clientCapabilities": { "session": { "promptSuggestions": prompt_suggestions } } + }); + let parsed = v1::ClientRequest::parse_message("initialize", ¶ms).unwrap(); + // v1 also writes its default capabilities, so compare `session` only. + assert_eq!( + parsed.to_untyped_message().unwrap().params()["clientCapabilities"]["session"], + params["clientCapabilities"]["session"] + ); + let v1::ClientRequest::InitializeRequest(request) = parsed else { + panic!("expected an initialize request"); + }; + assert!( + request + .client_capabilities + .session + .and_then(|session| session.prompt_suggestions) + .is_some() + ); + } + + // Only an object advertises support. + let capabilities: v1::ClientCapabilities = + serde_json::from_value(json!({ "session": { "promptSuggestions": true } })).unwrap(); + assert!( + capabilities + .session + .and_then(|session| session.prompt_suggestions) + .is_none() + ); +} + +#[cfg(feature = "unstable_speed_config_category")] +#[test] +fn v1_speed_config_option_category_round_trips() { + let option = json!({ + "type": "boolean", + "id": "fast", + "name": "Fast mode", + "category": "speed", + "currentValue": true + }); + let parsed: v1::SessionConfigOption = serde_json::from_value(option.clone()).unwrap(); + assert_eq!( + parsed.category, + Some(v1::SessionConfigOptionCategory::Speed) + ); + assert_eq!(serde_json::to_value(parsed).unwrap(), option); + assert_message_round_trips::( + "session/update", + &session_update(json!({ + "sessionUpdate": "config_option_update", + "configOptions": [option] + })), + ); +} + +#[cfg(all( + feature = "unstable_session_fork", + feature = "unstable_session_options" +))] +#[test] +fn v1_session_fork_carries_initial_config_options() { + let mut params = json!({ + "sessionId": "sess_1", + "cwd": "/work", + "configOptions": [ + { "configId": "model", "value": "model-2" }, + { "configId": "fast", "type": "boolean", "value": true } + ] + }); + let parsed = assert_message_round_trips::("session/fork", ¶ms); + let v1::ClientRequest::ForkSessionRequest(request) = parsed else { + panic!("expected a fork request"); + }; + assert_eq!(request.config_options.len(), 2); + + // `null` is an empty list. + params["configOptions"] = Value::Null; + let v1::ClientRequest::ForkSessionRequest(request) = + v1::ClientRequest::parse_message("session/fork", ¶ms).unwrap() + else { + panic!("expected a fork request"); + }; + assert_eq!(request.config_options, vec![]); + + // A malformed entry fails the request instead of being dropped. + params["configOptions"] = json!([{ "type": "boolean", "value": true }]); + assert_invalid_params::("session/fork", ¶ms); +} + +#[cfg(feature = "unstable_protocol_v2")] +mod v2_methods { + use agent_client_protocol::JsonRpcMessage; + use agent_client_protocol::v2::schema as v2; + use serde_json::{Value, json}; + + use super::{ + assert_invalid_params, assert_message_round_trips, assert_response_round_trips, + session_update, + }; + + /// Round-trip `update` through the v2 `session/update` route and return + /// the typed update. + fn round_trip_update(update: Value) -> v2::SessionUpdate { + let parsed = assert_message_round_trips::( + "session/update", + &session_update(update), + ); + let v2::AgentNotification::UpdateSessionNotification(notification) = parsed else { + panic!("expected a session update notification"); + }; + notification.update + } + + #[test] + fn v2_replay_complete_round_trips() { + assert!(matches!( + round_trip_update(json!({ "sessionUpdate": "replay_complete" })), + v2::SessionUpdate::ReplayComplete(_) + )); + assert!(matches!( + round_trip_update(json!({ + "sessionUpdate": "replay_complete", + "_meta": { "com.example/replayed": 12 } + })), + v2::SessionUpdate::ReplayComplete(replay) if replay.meta.is_some() + )); + assert_eq!( + serde_json::to_value(v2::SessionUpdate::ReplayComplete(v2::ReplayComplete::new())) + .unwrap(), + json!({ "sessionUpdate": "replay_complete" }) + ); + } + + /// Subagent updates, session-directed messages, the `unknown` state, + /// `subagent` background work and `session` tool call content are part of + /// v2 without `unstable_subagents`. + #[test] + fn v2_subagent_surface_is_typed_without_the_subagents_feature() { + assert!(matches!( + round_trip_update(json!({ + "sessionUpdate": "subagent_update", + "sessionId": "child", + "title": "Researcher", + "capabilities": { "cancel": {} } + })), + v2::SessionUpdate::SubagentUpdate(update) + if update.session_id == v2::SessionId::new("child") + )); + assert!(matches!( + round_trip_update(json!({ + "sessionUpdate": "session_message", + "messageId": "received_1", + "senderSessionId": "parent", + "recipientSessionId": "child" + })), + v2::SessionUpdate::SessionMessage(_) + )); + assert!(matches!( + round_trip_update(json!({ + "sessionUpdate": "session_message_chunk", + "messageId": "received_1", + "content": { "type": "text", "text": "Please investigate" } + })), + v2::SessionUpdate::SessionMessageChunk(_) + )); + + let v2::SessionUpdate::StateUpdate(v2::StateUpdate::Unknown(unknown)) = + round_trip_update(json!({ + "sessionUpdate": "state_update", + "state": "unknown", + "background": [ + { "type": "tool_call", "toolCallId": "call_1" }, + { "type": "subagent", "sessionId": "child" } + ] + })) + else { + panic!("expected the unknown state"); + }; + assert!(matches!( + unknown.background.as_deref(), + Some([ + v2::BackgroundWork::ToolCall(_), + v2::BackgroundWork::Subagent(subagent) + ]) if subagent.session_id == v2::SessionId::new("child") + )); + + let v2::SessionUpdate::ToolCallUpdate(tool_call) = round_trip_update(json!({ + "sessionUpdate": "tool_call_update", + "toolCallId": "call_1", + "content": [{ "type": "session", "sessionId": "child" }] + })) else { + panic!("expected a tool call update"); + }; + assert!(matches!( + tool_call.content.value().map(Vec::as_slice), + Some([v2::ToolCallContent::Session(_)]) + )); + } + + /// A malformed known variant is dropped like other malformed display + /// items. It is not read as a custom variant. + #[test] + fn v2_malformed_session_tool_call_content_is_dropped() { + let parsed = v2::AgentNotification::parse_message( + "session/update", + &session_update(json!({ + "sessionUpdate": "tool_call_update", + "toolCallId": "call_1", + "content": [{ "type": "session" }] + })), + ) + .unwrap(); + let untyped = parsed.to_untyped_message().unwrap(); + assert_eq!(untyped.params()["update"]["content"], json!([])); + } + + #[cfg(feature = "unstable_agent_skills")] + #[test] + fn v2_skill_commands_round_trip() { + let v2::SessionUpdate::AvailableCommandsUpdate(update) = round_trip_update(json!({ + "sessionUpdate": "available_commands_update", + "availableCommands": [ + { + "type": "skill", + "name": "pdf-processing", + "description": "Extract text and tables from PDF files", + "path": "/home/alice/.agents/skills/pdf-processing/SKILL.md" + }, + { "type": "command", "name": "test", "description": "Run project tests" } + ] + })) else { + panic!("expected an available commands update"); + }; + let [ + v2::AvailableCommand::Skill(skill), + v2::AvailableCommand::Command(_), + ] = update.available_commands.as_slice() + else { + panic!("expected a skill and a command"); + }; + assert_eq!(skill.name, "pdf-processing"); + assert!(skill.path.is_some()); + + // A malformed skill is skipped like any other invalid command. It is + // not kept as an unknown command type. + let parsed = v2::AgentNotification::parse_message( + "session/update", + &session_update(json!({ + "sessionUpdate": "available_commands_update", + "availableCommands": [ + { "type": "skill", "name": "no-description" }, + { "type": "command", "name": "test", "description": "Run project tests" } + ] + })), + ) + .unwrap(); + assert_eq!( + parsed.to_untyped_message().unwrap().params()["update"]["availableCommands"], + json!([{ "type": "command", "name": "test", "description": "Run project tests" }]) + ); + } + + #[cfg(feature = "unstable_agent_skills")] + #[test] + fn v2_prompt_carries_skill_references() { + let mut params = json!({ + "sessionId": "sess_1", + "prompt": [ + { "type": "skill", "name": "pdf-processing" }, + { "type": "text", "text": "Summarize report.pdf" } + ] + }); + let parsed = assert_message_round_trips::("session/prompt", ¶ms); + let v2::ClientRequest::PromptRequest(request) = parsed else { + panic!("expected a prompt request"); + }; + assert!(matches!( + request.prompt.as_slice(), + [v2::ContentBlock::Skill(skill), v2::ContentBlock::Text(_)] + if skill.name == "pdf-processing" + )); + + // The agent acts on `name`, so a malformed reference fails the prompt + // with `-32602`. It is not kept as an unknown content block. + params["prompt"] = json!([{ "type": "skill" }]); + assert_invalid_params::("session/prompt", ¶ms); + + // The agent reports the inserted user message with the block in place. + assert!(matches!( + round_trip_update(json!({ + "sessionUpdate": "user_message", + "messageId": "msg_1", + "content": [ + { "type": "skill", "name": "pdf-processing" }, + { "type": "text", "text": "Summarize report.pdf" } + ] + })), + v2::SessionUpdate::UserMessage(_) + )); + } + + #[cfg(feature = "unstable_agent_skills")] + #[test] + fn v2_skill_capabilities_round_trip() { + // The client advertises that it understands `skill` command entries. + let params = json!({ + "protocolVersion": 2, + "info": { "name": "client", "version": "1.0.0" }, + "capabilities": { "session": { "commands": { "skill": {} } } } + }); + let parsed = assert_message_round_trips::("initialize", ¶ms); + let v2::ClientRequest::InitializeRequest(request) = parsed else { + panic!("expected an initialize request"); + }; + assert!( + request + .capabilities + .session + .and_then(|session| session.commands) + .and_then(|commands| commands.skill) + .is_some() + ); + + // The agent advertises where a prompt may carry `skill` blocks: `{}` + // allows one leading block, `inline` allows any number anywhere. + for (skill, inline) in [(json!({}), false), (json!({ "inline": {} }), true)] { + let capabilities: v2::AgentCapabilities = serde_json::from_value(json!({ + "session": { "prompt": { "skill": skill } } + })) + .unwrap(); + let prompt_skill = capabilities + .session + .as_ref() + .and_then(|session| session.prompt.as_ref()) + .and_then(|prompt| prompt.skill.as_ref()) + .expect("the skill prompt capability should be advertised"); + assert_eq!(prompt_skill.inline.is_some(), inline); + assert_eq!( + serde_json::to_value(&capabilities).unwrap()["session"]["prompt"]["skill"], + skill + ); + } + } + + #[cfg(feature = "unstable_prompt_suggestions")] + #[test] + fn v2_prompt_suggestions_round_trip() { + let v2::SessionUpdate::PromptSuggestionsUpdate(update) = round_trip_update(json!({ + "sessionUpdate": "prompt_suggestions_update", + "suggestions": [ + { "prompt": [{ "type": "text", "text": "Run the tests" }] } + ] + })) else { + panic!("expected a prompt suggestions update"); + }; + assert_eq!(update.suggestions.len(), 1); + round_trip_update(json!({ + "sessionUpdate": "prompt_suggestions_update", + "suggestions": [] + })); + + // A suggestion the client cannot parse is skipped, and the list + // itself is required. + let parsed = v2::AgentNotification::parse_message( + "session/update", + &session_update(json!({ + "sessionUpdate": "prompt_suggestions_update", + "suggestions": [ + { "prompt": [{ "type": "text", "text": "Keep" }] }, + { "prompt": "not a list" } + ] + })), + ) + .unwrap(); + assert_eq!( + parsed.to_untyped_message().unwrap().params()["update"]["suggestions"], + json!([{ "prompt": [{ "type": "text", "text": "Keep" }] }]) + ); + assert_invalid_params::( + "session/update", + &session_update(json!({ "sessionUpdate": "prompt_suggestions_update" })), + ); + + let params = json!({ + "protocolVersion": 2, + "info": { "name": "client", "version": "1.0.0" }, + "capabilities": { "session": { "promptSuggestions": { "image": {} } } } + }); + let parsed = assert_message_round_trips::("initialize", ¶ms); + let v2::ClientRequest::InitializeRequest(request) = parsed else { + panic!("expected an initialize request"); + }; + assert!( + request + .capabilities + .session + .and_then(|session| session.prompt_suggestions) + .and_then(|suggestions| suggestions.image) + .is_some() + ); + } + + #[cfg(feature = "unstable_speed_config_category")] + #[test] + fn v2_speed_config_option_category_round_trips() { + let option = json!({ + "type": "boolean", + "configId": "fast", + "name": "Fast mode", + "category": "speed", + "currentValue": true + }); + let parsed: v2::SessionConfigOption = serde_json::from_value(option.clone()).unwrap(); + assert_eq!( + parsed.category, + Some(v2::SessionConfigOptionCategory::Speed) + ); + assert_eq!(serde_json::to_value(parsed).unwrap(), option); + round_trip_update(json!({ + "sessionUpdate": "config_option_update", + "configOptions": [option] + })); + } + + #[cfg(all( + feature = "unstable_session_fork", + feature = "unstable_session_options" + ))] + #[test] + fn v2_session_fork_carries_initial_config_options() { + let mut params = json!({ + "sessionId": "sess_1", + "cwd": "/work", + "configOptions": [ + { "configId": "model", "type": "id", "value": "model-2" }, + { "configId": "fast", "type": "boolean", "value": true } + ] + }); + let parsed = assert_message_round_trips::("session/fork", ¶ms); + let v2::ClientRequest::ForkSessionRequest(request) = parsed else { + panic!("expected a fork request"); + }; + assert_eq!(request.config_options.len(), 2); + + // Entries need `type`, and a malformed entry fails the request. + for entry in [ + json!({ "configId": "model", "value": "model-2" }), + json!({ "configId": "fast", "type": "boolean", "value": "yes" }), + ] { + params["configOptions"] = json!([entry]); + assert_invalid_params::("session/fork", ¶ms); + } + } +} diff --git a/src/agent-client-protocol/tests/schema_plan_operations.rs b/src/agent-client-protocol/tests/schema_plan_operations.rs index 3ccb155e..c36313d5 100644 --- a/src/agent-client-protocol/tests/schema_plan_operations.rs +++ b/src/agent-client-protocol/tests/schema_plan_operations.rs @@ -3,7 +3,7 @@ use agent_client_protocol::JsonRpcMessage; use agent_client_protocol::v1::schema::{ AgentNotification, ClientCapabilities, PlanCapabilities, PlanEntry, PlanEntryPriority, - PlanEntryStatus, PlanRemoved, PlanUpdate, PlanUpdateContent, SessionNotification, + PlanEntryStatus, PlanMarkdown, PlanRemoved, PlanUpdate, PlanUpdateContent, SessionNotification, SessionUpdate, }; use serde_json::{Value, json}; @@ -37,11 +37,13 @@ fn v1_plan_operations_are_capability_gated_and_routed() { PlanUpdateContent::items("plan-1", vec![plan_entry()]), "items", ), + (PlanUpdateContent::markdown("plan-1", "# Plan"), "markdown"), ( - PlanUpdateContent::file("plan-1", "file:///workspace/PLAN.md"), - "file", + PlanUpdateContent::Markdown( + PlanMarkdown::new("plan-1", "# Plan").path("/workspace/PLAN.md"), + ), + "markdown", ), - (PlanUpdateContent::markdown("plan-1", "# Plan"), "markdown"), ]; for (content, content_type) in contents { @@ -77,26 +79,116 @@ fn v1_plan_operations_are_capability_gated_and_routed() { )); } +/// Schema 1.18 replaced the `file` plan type with an optional `path` on +/// markdown plans. +#[test] +fn v1_markdown_plan_path_names_the_plan_file() { + let (params, parsed) = parse_v1_notification(SessionUpdate::PlanUpdate(PlanUpdate::new( + PlanUpdateContent::Markdown( + PlanMarkdown::new("plan-1", "# Plan").path("/workspace/PLAN.md"), + ), + ))); + assert_eq!( + params["update"]["plan"], + json!({ + "type": "markdown", + "planId": "plan-1", + "content": "# Plan", + "path": "/workspace/PLAN.md" + }) + ); + let AgentNotification::SessionNotification(SessionNotification { + update: SessionUpdate::PlanUpdate(update), + .. + }) = parsed + else { + panic!("expected a plan update"); + }; + let PlanUpdateContent::Markdown(markdown) = update.plan else { + panic!("expected a markdown plan"); + }; + assert_eq!( + markdown.path.as_deref(), + Some(std::path::Path::new("/workspace/PLAN.md")) + ); + + // `path` is optional: omitted, `null` and a malformed value all mean the + // plan has no file, and the content still parses. + for plan in [ + json!({ "type": "markdown", "planId": "plan-1", "content": "# Plan" }), + json!({ "type": "markdown", "planId": "plan-1", "content": "# Plan", "path": null }), + json!({ "type": "markdown", "planId": "plan-1", "content": "# Plan", "path": 42 }), + ] { + let content: PlanUpdateContent = serde_json::from_value(plan).unwrap(); + assert_eq!(content, PlanUpdateContent::markdown("plan-1", "# Plan")); + } + + // v1 has no fallback variant, so the removed `file` type no longer parses. + assert!( + serde_json::from_value::(json!({ + "type": "file", + "planId": "plan-1", + "uri": "file:///workspace/PLAN.md" + })) + .is_err() + ); +} + #[cfg(feature = "unstable_protocol_v2")] #[test] -fn draft_v2_file_and_removal_operations_are_routed() { +fn draft_v2_markdown_path_and_removal_operations_are_routed() { use agent_client_protocol::v2::schema as v2; let notification = v2::UpdateSessionNotification::new( "session-1", - v2::SessionUpdate::PlanUpdate(v2::PlanUpdate::new(v2::PlanUpdateContent::file( - "plan-1", - "file:///workspace/PLAN.md", + v2::SessionUpdate::PlanUpdate(v2::PlanUpdate::new(v2::PlanUpdateContent::Markdown( + v2::PlanMarkdown::new("plan-1", "# Plan").path("/workspace/PLAN.md"), ))), ); let untyped = notification.to_untyped_message().unwrap(); - assert_eq!(untyped.params["update"]["plan"]["type"], "file"); + assert_eq!( + untyped.params["update"]["plan"], + json!({ + "type": "markdown", + "planId": "plan-1", + "content": "# Plan", + "path": "/workspace/PLAN.md" + }) + ); let v2::AgentNotification::UpdateSessionNotification(parsed) = v2::AgentNotification::parse_message("session/update", &untyped.params).unwrap() else { panic!("expected a v2 session update notification"); }; - assert!(matches!(parsed.update, v2::SessionUpdate::PlanUpdate(_))); + assert!(matches!( + parsed.update, + v2::SessionUpdate::PlanUpdate(v2::PlanUpdate { + plan: v2::PlanUpdateContent::Markdown(_), + .. + }) + )); + + // The removed `file` type is an unknown plan type now. v2 keeps its + // payload for proxying instead of failing the update. + let file_plan = json!({ + "sessionId": "session-1", + "update": { + "sessionUpdate": "plan_update", + "plan": { "type": "file", "planId": "plan-1", "uri": "file:///workspace/PLAN.md" } + } + }); + let parsed = v2::AgentNotification::parse_message("session/update", &file_plan).unwrap(); + let v2::AgentNotification::UpdateSessionNotification(notification) = &parsed else { + panic!("expected a v2 session update notification"); + }; + assert!(matches!( + ¬ification.update, + v2::SessionUpdate::PlanUpdate(v2::PlanUpdate { + plan: v2::PlanUpdateContent::Other(_), + .. + }) + )); + assert_eq!(parsed.to_untyped_message().unwrap().params, file_plan); let notification = v2::UpdateSessionNotification::new( "session-1", diff --git a/src/agent-client-protocol/tests/schema_subagents.rs b/src/agent-client-protocol/tests/schema_subagents.rs index 30175eb9..9d1c0d1e 100644 --- a/src/agent-client-protocol/tests/schema_subagents.rs +++ b/src/agent-client-protocol/tests/schema_subagents.rs @@ -1,13 +1,18 @@ -#![cfg(feature = "unstable_subagents")] +//! Subagent updates use the existing `session/update` route. v1 needs +//! `unstable_subagents`. v2 includes subagents since schema 1.18. + +#![cfg(any(feature = "unstable_subagents", feature = "unstable_protocol_v2"))] -use agent_client_protocol::v1::schema as v1; use agent_client_protocol::{JsonRpcMessage, JsonRpcNotification}; use serde_json::json; fn assert_notification() {} +#[cfg(feature = "unstable_subagents")] #[test] fn v1_subagent_update_uses_existing_session_notification_route() { + use agent_client_protocol::v1::schema as v1; + let notification = v1::SessionNotification::new( "parent", v1::SessionUpdate::SubagentUpdate(v1::SubagentUpdate::new("child")),