From d588b8ccc93200df94bd2ad6655d5656dcc227fc Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Tue, 2 Jun 2026 17:20:44 +1000 Subject: [PATCH 01/10] Add ACP steering support Signed-off-by: Michael Neale --- crates/goose-sdk/src/custom_requests.rs | 21 + crates/goose/acp-meta.json | 5 + crates/goose/acp-schema.json | 441 ++++++++++++++++++ crates/goose/src/acp/server.rs | 210 ++++++++- .../goose/src/acp/server/custom_dispatch.rs | 8 + crates/goose/src/agents/agent.rs | 60 ++- .../goose/tests/acp_custom_requests_test.rs | 159 ++++++- ui/sdk/src/generated/client.gen.ts | 15 + ui/sdk/src/generated/index.ts | 7 +- ui/sdk/src/generated/types.gen.ts | 210 ++++++++- ui/sdk/src/generated/zod.gen.ts | 214 +++++++++ 11 files changed, 1322 insertions(+), 28 deletions(-) diff --git a/crates/goose-sdk/src/custom_requests.rs b/crates/goose-sdk/src/custom_requests.rs index 5b007574bbd5..4f11ca712ba4 100644 --- a/crates/goose-sdk/src/custom_requests.rs +++ b/crates/goose-sdk/src/custom_requests.rs @@ -1,3 +1,4 @@ +use agent_client_protocol::schema::ContentBlock; use agent_client_protocol::{JsonRpcRequest, JsonRpcResponse}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -139,6 +140,26 @@ pub struct SetSessionSystemPromptRequest { pub text: String, } +/// Add user input to the currently active prompt without starting a new prompt. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/unstable/session/steer", + response = SteerSessionResponse +)] +#[serde(rename_all = "camelCase")] +pub struct SteerSessionRequest { + pub session_id: String, + #[serde(default)] + pub prompt: Vec, + pub expected_run_id: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct SteerSessionResponse { + pub run_id: String, +} + /// Delete a session. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] #[request(method = "session/delete", response = EmptyResponse)] diff --git a/crates/goose/acp-meta.json b/crates/goose/acp-meta.json index e1d6679ef976..ee51aa020de1 100644 --- a/crates/goose/acp-meta.json +++ b/crates/goose/acp-meta.json @@ -35,6 +35,11 @@ "requestType": "SetSessionSystemPromptRequest_unstable", "responseType": "EmptyResponse" }, + { + "method": "_goose/unstable/session/steer", + "requestType": "SteerSessionRequest_unstable", + "responseType": "SteerSessionResponse_unstable" + }, { "method": "session/delete", "requestType": "DeleteSessionRequest", diff --git a/crates/goose/acp-schema.json b/crates/goose/acp-schema.json index 9afa77e6c4a0..10f7be4ef55c 100644 --- a/crates/goose/acp-schema.json +++ b/crates/goose/acp-schema.json @@ -210,6 +210,430 @@ ], "description": "How a session system prompt update should be applied." }, + "SteerSessionRequest_unstable": { + "type": "object", + "properties": { + "sessionId": { + "type": "string" + }, + "prompt": { + "type": "array", + "items": { + "$ref": "#/$defs/ContentBlock" + }, + "default": [] + }, + "expectedRunId": { + "type": "string" + } + }, + "required": [ + "sessionId", + "expectedRunId" + ], + "description": "Add user input to the currently active prompt without starting a new prompt.", + "x-side": "agent", + "x-method": "_goose/unstable/session/steer" + }, + "ContentBlock": { + "oneOf": [ + { + "$ref": "#/$defs/TextContent", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + } + }, + "required": [ + "type" + ], + "description": "Text content. May be plain text or formatted with Markdown.\n\nAll agents MUST support text content blocks in prompts.\nClients SHOULD render this text as Markdown." + }, + { + "$ref": "#/$defs/ImageContent", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "image" + } + }, + "required": [ + "type" + ], + "description": "Images for visual context or analysis.\n\nRequires the `image` prompt capability when included in prompts." + }, + { + "$ref": "#/$defs/AudioContent", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "audio" + } + }, + "required": [ + "type" + ], + "description": "Audio data for transcription or analysis.\n\nRequires the `audio` prompt capability when included in prompts." + }, + { + "$ref": "#/$defs/ResourceLink", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "resource_link" + } + }, + "required": [ + "type" + ], + "description": "References to resources that the agent can access.\n\nAll agents MUST support resource links in prompts." + }, + { + "$ref": "#/$defs/EmbeddedResource", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "resource" + } + }, + "required": [ + "type" + ], + "description": "Complete resource contents embedded directly in the message.\n\nPreferred for including context as it avoids extra round-trips.\n\nRequires the `embeddedContext` prompt capability when included in prompts." + } + ], + "description": "Content blocks represent displayable information in the Agent Client Protocol.\n\nThey provide a structured way to handle various types of user-facing content—whether\nit's text from language models, images for analysis, or embedded resources for context.\n\nContent blocks appear in:\n- User prompts sent via `session/prompt`\n- Language model output streamed through `session/update` notifications\n- Progress updates and results from tool calls\n\nThis structure is compatible with the Model Context Protocol (MCP), enabling\nagents to seamlessly forward content from MCP tool outputs without transformation.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/content)", + "discriminator": { + "propertyName": "type" + } + }, + "Annotations": { + "type": "object", + "properties": { + "audience": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/Role" + } + }, + "lastModified": { + "type": [ + "string", + "null" + ] + }, + "priority": { + "type": [ + "number", + "null" + ], + "format": "double" + }, + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": {}, + "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)" + } + }, + "description": "Optional annotations for the client. The client can use annotations to inform how objects are used or displayed" + }, + "Role": { + "type": "string", + "enum": [ + "assistant", + "user" + ], + "description": "The sender or recipient of messages and data in a conversation." + }, + "TextContent": { + "type": "object", + "properties": { + "annotations": { + "anyOf": [ + { + "$ref": "#/$defs/Annotations" + }, + { + "type": "null" + } + ] + }, + "text": { + "type": "string" + }, + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": {}, + "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)" + } + }, + "required": [ + "text" + ], + "description": "Text provided to or from an LLM." + }, + "ImageContent": { + "type": "object", + "properties": { + "annotations": { + "anyOf": [ + { + "$ref": "#/$defs/Annotations" + }, + { + "type": "null" + } + ] + }, + "data": { + "type": "string" + }, + "mimeType": { + "type": "string" + }, + "uri": { + "type": [ + "string", + "null" + ] + }, + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": {}, + "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)" + } + }, + "required": [ + "data", + "mimeType" + ], + "description": "An image provided to or from an LLM." + }, + "AudioContent": { + "type": "object", + "properties": { + "annotations": { + "anyOf": [ + { + "$ref": "#/$defs/Annotations" + }, + { + "type": "null" + } + ] + }, + "data": { + "type": "string" + }, + "mimeType": { + "type": "string" + }, + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": {}, + "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)" + } + }, + "required": [ + "data", + "mimeType" + ], + "description": "Audio provided to or from an LLM." + }, + "ResourceLink": { + "type": "object", + "properties": { + "annotations": { + "anyOf": [ + { + "$ref": "#/$defs/Annotations" + }, + { + "type": "null" + } + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "mimeType": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "size": { + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "uri": { + "type": "string" + }, + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": {}, + "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)" + } + }, + "required": [ + "name", + "uri" + ], + "description": "A resource that the server is capable of reading, included in a prompt or tool call result." + }, + "EmbeddedResourceResource": { + "anyOf": [ + { + "$ref": "#/$defs/TextResourceContents" + }, + { + "$ref": "#/$defs/BlobResourceContents" + } + ], + "description": "Resource content that can be embedded in a message." + }, + "TextResourceContents": { + "type": "object", + "properties": { + "mimeType": { + "type": [ + "string", + "null" + ] + }, + "text": { + "type": "string" + }, + "uri": { + "type": "string" + }, + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": {}, + "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)" + } + }, + "required": [ + "text", + "uri" + ], + "description": "Text-based resource contents." + }, + "BlobResourceContents": { + "type": "object", + "properties": { + "blob": { + "type": "string" + }, + "mimeType": { + "type": [ + "string", + "null" + ] + }, + "uri": { + "type": "string" + }, + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": {}, + "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)" + } + }, + "required": [ + "blob", + "uri" + ], + "description": "Binary resource contents." + }, + "EmbeddedResource": { + "type": "object", + "properties": { + "annotations": { + "anyOf": [ + { + "$ref": "#/$defs/Annotations" + }, + { + "type": "null" + } + ] + }, + "resource": { + "$ref": "#/$defs/EmbeddedResourceResource" + }, + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": {}, + "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)" + } + }, + "required": [ + "resource" + ], + "description": "The contents of a resource, embedded into a prompt or tool call result." + }, + "SteerSessionResponse_unstable": { + "type": "object", + "properties": { + "runId": { + "type": "string" + } + }, + "required": [ + "runId" + ], + "x-side": "agent", + "x-method": "_goose/unstable/session/steer" + }, "DeleteSessionRequest": { "type": "object", "properties": { @@ -2726,6 +3150,15 @@ "description": "Params for _goose/unstable/session/system-prompt/set", "title": "SetSessionSystemPromptRequest_unstable" }, + { + "allOf": [ + { + "$ref": "#/$defs/SteerSessionRequest_unstable" + } + ], + "description": "Params for _goose/unstable/session/steer", + "title": "SteerSessionRequest_unstable" + }, { "allOf": [ { @@ -3238,6 +3671,14 @@ ], "title": "ReadResourceResponse_unstable" }, + { + "allOf": [ + { + "$ref": "#/$defs/SteerSessionResponse_unstable" + } + ], + "title": "SteerSessionResponse_unstable" + }, { "allOf": [ { diff --git a/crates/goose/src/acp/server.rs b/crates/goose/src/acp/server.rs index b95010df96df..fa45c3749165 100644 --- a/crates/goose/src/acp/server.rs +++ b/crates/goose/src/acp/server.rs @@ -71,6 +71,7 @@ use tokio_util::compat::{TokioAsyncReadCompatExt as _, TokioAsyncWriteCompatExt use tokio_util::sync::CancellationToken; use tracing::{debug, error, info, warn}; use url::Url; +use uuid::Uuid; mod config; mod custom_dispatch; @@ -176,6 +177,7 @@ struct GooseAcpSession { /// Idempotence guard so we summarize each chain at most once. summarized_chains: HashSet, cancel_token: Option, + active_run_id: Option, /// Working directory set while the agent was still loading. /// Applied once the agent becomes ready. pending_working_dir: Option, @@ -2624,6 +2626,7 @@ impl GooseAcpAgent { responded_tool_ids: HashSet::new(), summarized_chains: HashSet::new(), cancel_token: None, + active_run_id: None, pending_working_dir: None, }; self.sessions @@ -2762,6 +2765,92 @@ impl GooseAcpAgent { } } + async fn start_active_run( + &self, + session_id: &str, + run_id: String, + cancel_token: CancellationToken, + ) -> Result<(), agent_client_protocol::Error> { + let mut sessions = self.sessions.lock().await; + let session = sessions.get_mut(session_id).ok_or_else(|| { + agent_client_protocol::Error::resource_not_found(Some(session_id.to_string())) + .data(format!("Session not found: {}", session_id)) + })?; + + if let Some(active_run_id) = &session.active_run_id { + return Err(agent_client_protocol::Error::invalid_params().data(format!( + "session already has active run `{active_run_id}`; use _goose/unstable/session/steer" + ))); + } + + session.cancel_token = Some(cancel_token); + session.active_run_id = Some(run_id); + Ok(()) + } + + async fn clear_active_run(&self, session_id: &str, run_id: &str) { + let mut sessions = self.sessions.lock().await; + if let Some(session) = sessions.get_mut(session_id) { + if session.active_run_id.as_deref() == Some(run_id) { + session.cancel_token = None; + session.active_run_id = None; + } + } + } + + async fn require_active_run( + &self, + session_id: &str, + expected_run_id: &str, + ) -> Result { + if expected_run_id.is_empty() { + return Err(agent_client_protocol::Error::invalid_params() + .data("expectedRunId must not be empty")); + } + + let sessions = self.sessions.lock().await; + let session = sessions.get(session_id).ok_or_else(|| { + agent_client_protocol::Error::resource_not_found(Some(session_id.to_string())) + .data(format!("Session not found: {}", session_id)) + })?; + let active_run_id = session.active_run_id.as_ref().ok_or_else(|| { + agent_client_protocol::Error::invalid_params().data("no active run to steer") + })?; + if active_run_id != expected_run_id { + return Err(agent_client_protocol::Error::invalid_params().data(format!( + "expected active run id `{expected_run_id}` but found `{active_run_id}`" + ))); + } + Ok(active_run_id.clone()) + } + + fn active_run_meta(active_run_id: Option<&str>) -> Meta { + let mut goose = serde_json::Map::new(); + goose.insert( + "activeRunId".to_string(), + active_run_id + .map(|run_id| serde_json::Value::String(run_id.to_string())) + .unwrap_or(serde_json::Value::Null), + ); + + let mut meta = serde_json::Map::new(); + meta.insert("goose".to_string(), serde_json::Value::Object(goose)); + meta + } + + fn send_active_run_update( + cx: &ConnectionTo, + session_id: &SessionId, + active_run_id: Option<&str>, + ) -> Result<(), agent_client_protocol::Error> { + cx.send_notification(SessionNotification::new( + session_id.clone(), + SessionUpdate::SessionInfoUpdate( + SessionInfoUpdate::new().meta(Self::active_run_meta(active_run_id)), + ), + )) + } + async fn add_mcp_extensions( agent: &Arc, mcp_servers: Vec, @@ -2990,6 +3079,7 @@ impl GooseAcpAgent { responded_tool_ids: HashSet::new(), summarized_chains: HashSet::new(), cancel_token: None, + active_run_id: None, pending_working_dir: None, }; self.sessions @@ -3059,10 +3149,23 @@ impl GooseAcpAgent { let sid = sid_short(&session_id); let t_start = std::time::Instant::now(); + let run_id = format!("run_{}", Uuid::new_v4()); let cancel_token = CancellationToken::new(); - let agent = self - .get_session_agent(&session_id, Some(cancel_token.clone())) + self.start_active_run(&session_id, run_id.clone(), cancel_token.clone()) .await?; + if let Err(error) = Self::send_active_run_update(cx, &args.session_id, Some(&run_id)) { + self.clear_active_run(&session_id, &run_id).await; + return Err(error); + } + + let agent = match self.get_session_agent(&session_id, None).await { + Ok(agent) => agent, + Err(error) => { + self.clear_active_run(&session_id, &run_id).await; + let _ = Self::send_active_run_update(cx, &args.session_id, None); + return Err(error); + } + }; let user_message = Self::convert_acp_prompt_to_message(&args.prompt); @@ -3077,7 +3180,7 @@ impl GooseAcpAgent { ) { if recipe_path.exists() { - cx.send_notification(SessionNotification::new( + if let Err(error) = cx.send_notification(SessionNotification::new( args.session_id.clone(), SessionUpdate::AgentMessageChunk(ContentChunk::new( ContentBlock::Text(TextContent::new(format!( @@ -3085,7 +3188,11 @@ impl GooseAcpAgent { full_command ))), )), - ))?; + )) { + self.clear_active_run(&session_id, &run_id).await; + let _ = Self::send_active_run_update(cx, &args.session_id, None); + return Err(error); + } } } } @@ -3098,10 +3205,18 @@ impl GooseAcpAgent { retry_config: None, }; - let mut stream = agent + let mut stream = match agent .reply(user_message, session_config, Some(cancel_token.clone())) .await - .internal_err_ctx("Error getting agent reply")?; + { + Ok(stream) => stream, + Err(error) => { + self.clear_active_run(&session_id, &run_id).await; + let _ = Self::send_active_run_update(cx, &args.session_id, None); + return Err(agent_client_protocol::Error::internal_error() + .data(format!("Error getting agent reply: {error}"))); + } + }; let mut was_cancelled = false; let mut first_event_logged = false; @@ -3117,6 +3232,7 @@ impl GooseAcpAgent { // `handle_tool_response` finds the chain when subsequent responses // are processed. let mut chain_buffer: Vec<(String, String)> = Vec::new(); + let mut stream_error = None; while let Some(event) = stream.next().await { if cancel_token.is_cancelled() { @@ -3140,10 +3256,13 @@ impl GooseAcpAgent { let stored_message_id = message.id.clone(); let mut sessions = self.sessions.lock().await; - let session = sessions.get_mut(&session_id).ok_or_else(|| { - agent_client_protocol::Error::invalid_params() - .data(format!("Session not found: {}", session_id)) - })?; + let Some(session) = sessions.get_mut(&session_id) else { + stream_error = Some( + agent_client_protocol::Error::invalid_params() + .data(format!("Session not found: {}", session_id)), + ); + break; + }; for content_item in &message.content { match content_item { @@ -3173,22 +3292,33 @@ impl GooseAcpAgent { } } - self.handle_message_content( - content_item, - &args.session_id, - &session_id, - stored_message_id.as_deref(), - &agent, - session, - cx, - ) - .await?; + if let Err(error) = self + .handle_message_content( + content_item, + &args.session_id, + &session_id, + stored_message_id.as_deref(), + &agent, + session, + cx, + ) + .await + { + stream_error = Some(error); + break; + } + } + if stream_error.is_some() { + break; } } Ok(_) => {} Err(e) => { - return Err(agent_client_protocol::Error::internal_error() - .data(format!("Error in agent response stream: {}", e))); + stream_error = Some( + agent_client_protocol::Error::internal_error() + .data(format!("Error in agent response stream: {}", e)), + ); + break; } } } @@ -3201,9 +3331,13 @@ impl GooseAcpAgent { // registered. (Eager registration during the loop usually // covers this.) extend_chain_membership(&chain_buffer, &mut session.chain_membership); - session.cancel_token = None; } } + self.clear_active_run(&session_id, &run_id).await; + Self::send_active_run_update(cx, &args.session_id, None)?; + if let Some(error) = stream_error { + return Err(error); + } let session = self .session_manager @@ -3242,6 +3376,35 @@ impl GooseAcpAgent { Ok(response) } + async fn on_steer_session( + &self, + req: SteerSessionRequest, + ) -> Result { + if req.prompt.is_empty() { + return Err( + agent_client_protocol::Error::invalid_params().data("prompt must not be empty") + ); + } + + self.require_active_run(&req.session_id, &req.expected_run_id) + .await?; + let agent = self.get_session_agent(&req.session_id, None).await?; + let active_run_id = self + .require_active_run(&req.session_id, &req.expected_run_id) + .await?; + + let message = Self::convert_acp_prompt_to_message(&req.prompt); + if message.content.is_empty() { + return Err(agent_client_protocol::Error::invalid_params() + .data("prompt must contain steerable content")); + } + agent.steer(&req.session_id, message).await; + + Ok(SteerSessionResponse { + run_id: active_run_id, + }) + } + async fn on_cancel( &self, args: CancelNotification, @@ -3560,6 +3723,7 @@ impl GooseAcpAgent { responded_tool_ids: HashSet::new(), summarized_chains: HashSet::new(), cancel_token: None, + active_run_id: None, pending_working_dir: None, }; self.sessions diff --git a/crates/goose/src/acp/server/custom_dispatch.rs b/crates/goose/src/acp/server/custom_dispatch.rs index 81c6e67107e1..c526b99d3d4f 100644 --- a/crates/goose/src/acp/server/custom_dispatch.rs +++ b/crates/goose/src/acp/server/custom_dispatch.rs @@ -67,6 +67,14 @@ impl GooseAcpAgent { self.on_set_session_system_prompt(req).await } + #[custom_method(SteerSessionRequest)] + async fn dispatch_steer_session( + &self, + req: SteerSessionRequest, + ) -> Result { + self.on_steer_session(req).await + } + #[custom_method(DeleteSessionRequest)] async fn dispatch_delete_session( &self, diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 91f2e8748016..e45b468a1823 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; use std::fmt; use std::future::Future; use std::pin::Pin; @@ -246,6 +246,7 @@ pub struct Agent { container: Mutex>, goal: Mutex>, grind: Mutex>, + pending_steers: Mutex>>, } #[derive(Clone, Debug)] @@ -367,6 +368,7 @@ impl Agent { container: Mutex::new(None), goal: Mutex::new(None), grind: Mutex::new(None), + pending_steers: Mutex::new(HashMap::new()), } } @@ -402,6 +404,32 @@ impl Agent { .await; } + pub async fn steer(&self, session_id: &str, message: Message) { + self.pending_steers + .lock() + .await + .entry(session_id.to_string()) + .or_default() + .push_back(message); + } + + async fn has_pending_steers(&self, session_id: &str) -> bool { + self.pending_steers + .lock() + .await + .get(session_id) + .is_some_and(|messages| !messages.is_empty()) + } + + async fn drain_pending_steers(&self, session_id: &str) -> Vec { + self.pending_steers + .lock() + .await + .remove(session_id) + .map(|messages| messages.into_iter().collect()) + .unwrap_or_default() + } + async fn emit_pre_tool_extended_hooks( &self, tool_name: &str, @@ -1702,12 +1730,35 @@ impl Agent { let mut retrying_after_stop_hook_denial = false; let mut consecutive_stop_hook_blocks = 0u32; let stop_hook_block_cap = self.stop_hook_block_cap(); + let mut can_drain_pending_steers = false; loop { if is_token_cancelled(&cancel_token) { break; } + if can_drain_pending_steers { + for message in self.drain_pending_steers(&session_config.id).await { + let message_text = message.as_concat_text(); + if self + .hook_manager + .has_hooks(crate::hooks::HookEvent::UserPromptSubmit) + { + let ctx = crate::hooks::HookContext::new( + crate::hooks::HookEvent::UserPromptSubmit, + &session_config.id, + ) + .with_message(message_text); + self.hook_manager + .emit(crate::hooks::HookEvent::UserPromptSubmit, ctx) + .await; + } + session_manager.add_message(&session_config.id, &message).await?; + conversation.push(message.clone()); + yield AgentEvent::Message(message); + } + } + let final_output = { let mut guard = self.final_output_tool.lock().await; guard.as_mut().and_then(|fot| fot.final_output.take()) @@ -2212,6 +2263,8 @@ impl Agent { } } } + can_drain_pending_steers = true; + if tools_updated { (tools, toolshim_tools, system_prompt) = self.prepare_tools_and_prompt(&session_config.id, &session.working_dir).await?; @@ -2251,6 +2304,7 @@ impl Agent { None if did_recovery_compact_this_iteration => { // continue from last user message after recovery compact } + None if self.has_pending_steers(&session_config.id).await => {} None if self.goal.lock().await.is_some() && !goal_check_pending => { goal_check_pending = true; let goal = self.goal.lock().await.clone().unwrap(); @@ -2374,6 +2428,10 @@ impl Agent { } conversation.extend(messages_to_add); + if exit_chat && self.has_pending_steers(&session_config.id).await { + exit_chat = false; + } + if exit_chat { let ctx = crate::hooks::HookContext::new( crate::hooks::HookEvent::Stop, diff --git a/crates/goose/tests/acp_custom_requests_test.rs b/crates/goose/tests/acp_custom_requests_test.rs index 24e237e49606..852fb0878d91 100644 --- a/crates/goose/tests/acp_custom_requests_test.rs +++ b/crates/goose/tests/acp_custom_requests_test.rs @@ -2,18 +2,24 @@ #[path = "acp_common_tests/mod.rs"] mod common_tests; +use agent_client_protocol::schema::{ + ContentBlock, PromptRequest, SessionUpdate, StopReason, TextContent, +}; use common_tests::fixtures::server::AcpServerConnection; use common_tests::fixtures::{ run_test, send_custom, Connection, PermissionDecision, Session, SessionData, TestConnectionConfig, }; use goose::acp::server::AcpProviderFactory; +use goose::conversation::message::Message; use goose::model::ModelConfig; -use goose::providers::base::{MessageStream, Provider}; +use goose::providers::base::{MessageStream, Provider, ProviderUsage, Usage}; use goose::providers::errors::ProviderError; use goose_test_support::{EnforceSessionId, IgnoreSessionId}; use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; +use std::time::Duration; use common_tests::fixtures::OpenAiFixture; @@ -74,6 +80,87 @@ fn mock_provider_factory() -> AcpProviderFactory { }) } +struct SteeringProvider { + model_config: ModelConfig, + call_count: AtomicUsize, +} + +#[async_trait::async_trait] +impl Provider for SteeringProvider { + fn get_name(&self) -> &str { + "steering-test" + } + + async fn stream( + &self, + _model_config: &ModelConfig, + _session_id: &str, + _system: &str, + messages: &[Message], + _tools: &[rmcp::model::Tool], + ) -> Result { + let call = self.call_count.fetch_add(1, Ordering::SeqCst); + let usage = ProviderUsage::new(self.model_config.model_name.clone(), Usage::default()); + let saw_steer = messages + .iter() + .any(|message| message.as_concat_text().contains("steer while active")); + + let text = match (call, saw_steer) { + (0, _) => { + tokio::time::sleep(Duration::from_millis(200)).await; + "first response" + } + (_, true) => "saw steer", + _ => "missing steer", + }; + + let message = Message::assistant().with_text(text); + Ok(Box::pin(futures::stream::once(async move { + Ok((Some(message), Some(usage))) + }))) + } + + fn get_model_config(&self) -> ModelConfig { + self.model_config.clone() + } +} + +fn steering_provider_factory() -> AcpProviderFactory { + Arc::new(|_provider_name, model_config, _extensions, _working_dir| { + Box::pin(async move { + Ok(Arc::new(SteeringProvider { + model_config, + call_count: AtomicUsize::new(0), + }) as Arc) + }) + }) +} + +fn active_run_id_from_update(update: &SessionUpdate) -> Option { + let SessionUpdate::SessionInfoUpdate(info) = update else { + return None; + }; + info.meta + .as_ref()? + .get("goose")? + .get("activeRunId")? + .as_str() + .map(ToString::to_string) +} + +fn collect_agent_text(updates: &[SessionUpdate]) -> String { + updates + .iter() + .filter_map(|update| match update { + SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content { + ContentBlock::Text(text) => Some(text.text.as_str()), + _ => None, + }, + _ => None, + }) + .collect() +} + #[test] fn test_custom_get_tools() { run_test(async move { @@ -123,6 +210,76 @@ fn test_custom_get_extensions() { }); } +#[test] +fn test_steer_session_adds_input_to_active_prompt() { + run_test(async move { + let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await; + let mut conn = AcpServerConnection::new( + TestConnectionConfig { + provider_factory: Some(steering_provider_factory()), + ..Default::default() + }, + openai, + ) + .await; + + let SessionData { session, .. } = conn.new_session().await.unwrap(); + let session_id = session.session_id().0.to_string(); + let acp_session_id = session.session_id().clone(); + + let mut prompt = Box::pin( + conn.cx() + .send_request(PromptRequest::new( + acp_session_id, + vec![ContentBlock::Text(TextContent::new("start work"))], + )) + .block_task(), + ); + let mut steer_sent = false; + let mut final_response = None; + let deadline = tokio::time::Instant::now() + Duration::from_secs(3); + + while tokio::time::Instant::now() < deadline { + tokio::select! { + response = &mut prompt => { + final_response = Some(response.unwrap()); + break; + } + _ = tokio::time::sleep(Duration::from_millis(10)), if !steer_sent => { + let updates = session.session_updates(); + if let Some(run_id) = updates.iter().find_map(active_run_id_from_update) { + let response = send_custom( + conn.cx(), + "_goose/unstable/session/steer", + serde_json::json!({ + "sessionId": session_id, + "expectedRunId": run_id, + "prompt": [ + { "type": "text", "text": "steer while active" } + ] + }), + ) + .await + .unwrap(); + assert_eq!(response["runId"], run_id); + steer_sent = true; + } + } + } + } + + let response = final_response.expect("prompt did not complete"); + assert_eq!(response.stop_reason, StopReason::EndTurn); + assert!(steer_sent, "test never observed an active run id"); + + let agent_text = collect_agent_text(&session.session_updates()); + assert!( + agent_text.contains("saw steer"), + "expected provider to receive steered input, got: {agent_text:?}" + ); + }); +} + #[test] fn test_new_session_passes_cwd_to_provider_factory() { run_test(async move { diff --git a/ui/sdk/src/generated/client.gen.ts b/ui/sdk/src/generated/client.gen.ts index 22beacb47533..7bb2ed005a54 100644 --- a/ui/sdk/src/generated/client.gen.ts +++ b/ui/sdk/src/generated/client.gen.ts @@ -92,6 +92,8 @@ import type { RemoveExtensionRequest_unstable, RenameSessionRequest_unstable, SetSessionSystemPromptRequest_unstable, + SteerSessionRequest_unstable, + SteerSessionResponse_unstable, ToggleConfigExtensionRequest_unstable, UnarchiveSessionRequest_unstable, UpdateSessionProjectRequest_unstable, @@ -132,6 +134,7 @@ import { zProviderSupportedModelsListResponse_unstable, zReadResourceResponse_unstable, zRefreshProviderInventoryResponse_unstable, + zSteerSessionResponse_unstable, zUpdateSourceResponse_unstable, } from './zod.gen.js'; @@ -199,6 +202,18 @@ export class GooseExtClient { ); } + async sessionSteer_unstable( + params: SteerSessionRequest_unstable, + ): Promise { + const raw = await this.conn.extMethod( + "_goose/unstable/session/steer", + params, + ); + return zSteerSessionResponse_unstable.parse( + raw, + ) as SteerSessionResponse_unstable; + } + async sessionDelete(params: DeleteSessionRequest): Promise { await this.conn.extMethod("session/delete", params); } diff --git a/ui/sdk/src/generated/index.ts b/ui/sdk/src/generated/index.ts index 85a63bc04964..59c885080471 100644 --- a/ui/sdk/src/generated/index.ts +++ b/ui/sdk/src/generated/index.ts @@ -1,6 +1,6 @@ // This file is auto-generated by @hey-api/openapi-ts -export type { AddConfigExtensionRequest_unstable, AddExtensionRequest_unstable, ArchiveSessionRequest_unstable, CreateSourceRequest_unstable, CreateSourceResponse_unstable, CustomProviderConfigDto, CustomProviderCreateRequest_unstable, CustomProviderCreateResponse_unstable, CustomProviderDeleteRequest_unstable, CustomProviderDeleteResponse_unstable, CustomProviderReadRequest_unstable, CustomProviderReadResponse_unstable, CustomProviderUpdateRequest_unstable, CustomProviderUpdateResponse_unstable, DefaultsReadRequest_unstable, DefaultsReadResponse_unstable, DefaultsSaveRequest_unstable, DeleteSessionRequest, DeleteSourceRequest_unstable, DictationConfigRequest_unstable, DictationConfigResponse_unstable, DictationDownloadProgress, DictationLocalModelStatus, DictationModelCancelRequest_unstable, DictationModelDeleteRequest_unstable, DictationModelDownloadProgressRequest_unstable, DictationModelDownloadProgressResponse_unstable, DictationModelDownloadRequest_unstable, DictationModelOption, DictationModelSelectRequest_unstable, DictationModelsListRequest_unstable, DictationModelsListResponse_unstable, DictationProviderStatusEntry, DictationSecretDeleteRequest_unstable, DictationSecretSaveRequest_unstable, DictationTranscribeRequest_unstable, DictationTranscribeResponse_unstable, EmptyResponse, ExportSessionRequest_unstable, ExportSessionResponse_unstable, ExportSourceRequest_unstable, ExportSourceResponse_unstable, ExtRequest, ExtResponse, GetExtensionsRequest_unstable, GetExtensionsResponse_unstable, GetSessionExtensionsRequest_unstable, GetSessionExtensionsResponse_unstable, GetToolsRequest_unstable, GetToolsResponse_unstable, GooseToolCallRequest_unstable, GooseToolCallResponse_unstable, ImportSessionRequest_unstable, ImportSessionResponse_unstable, ImportSourcesRequest_unstable, ImportSourcesResponse_unstable, ListProvidersRequest_unstable, ListProvidersResponse_unstable, ListSourcesRequest_unstable, ListSourcesResponse_unstable, OnboardingImportApplyRequest_unstable, OnboardingImportApplyResponse_unstable, OnboardingImportCandidate, OnboardingImportCounts, OnboardingImportScanRequest_unstable, OnboardingImportScanResponse_unstable, OnboardingImportSourceKind, PreferenceKey, PreferencesReadRequest_unstable, PreferencesReadResponse_unstable, PreferencesRemoveRequest_unstable, PreferencesSaveRequest_unstable, PreferenceValue, ProviderCatalogListRequest_unstable, ProviderCatalogListResponse_unstable, ProviderCatalogTemplateRequest_unstable, ProviderCatalogTemplateResponse_unstable, ProviderConfigAuthenticateRequest_unstable, ProviderConfigChangeResponse_unstable, ProviderConfigDeleteRequest_unstable, ProviderConfigFieldUpdate, ProviderConfigFieldValueDto, ProviderConfigKey, ProviderConfigReadRequest_unstable, ProviderConfigReadResponse_unstable, ProviderConfigSaveRequest_unstable, ProviderConfigStatusDto, ProviderConfigStatusRequest_unstable, ProviderConfigStatusResponse_unstable, ProviderInventoryEntryDto, ProviderInventoryModelDto, ProviderSetupCatalogEntryDto, ProviderSetupCatalogListRequest_unstable, ProviderSetupCatalogListResponse_unstable, ProviderSetupCategoryDto, ProviderSetupFieldDto, ProviderSetupGroupDto, ProviderSetupMethodDto, ProviderSupportedModelsListRequest_unstable, ProviderSupportedModelsListResponse_unstable, ProviderTemplateCapabilitiesDto, ProviderTemplateCatalogEntryDto, ProviderTemplateDto, ProviderTemplateModelDto, ReadResourceRequest_unstable, ReadResourceResponse_unstable, RefreshProviderInventoryRequest_unstable, RefreshProviderInventoryResponse_unstable, RefreshProviderInventorySkipDto, RefreshProviderInventorySkipReasonDto, RemoveConfigExtensionRequest_unstable, RemoveExtensionRequest_unstable, RenameSessionRequest_unstable, SessionSystemPromptMode, SetSessionSystemPromptRequest_unstable, SourceEntry, SourceScope, SourceType, ToggleConfigExtensionRequest_unstable, UnarchiveSessionRequest_unstable, UpdateSessionProjectRequest_unstable, UpdateSourceRequest_unstable, UpdateSourceResponse_unstable, UpdateWorkingDirRequest_unstable } from './types.gen.js'; +export type { AddConfigExtensionRequest_unstable, AddExtensionRequest_unstable, Annotations, ArchiveSessionRequest_unstable, AudioContent, BlobResourceContents, ContentBlock, CreateSourceRequest_unstable, CreateSourceResponse_unstable, CustomProviderConfigDto, CustomProviderCreateRequest_unstable, CustomProviderCreateResponse_unstable, CustomProviderDeleteRequest_unstable, CustomProviderDeleteResponse_unstable, CustomProviderReadRequest_unstable, CustomProviderReadResponse_unstable, CustomProviderUpdateRequest_unstable, CustomProviderUpdateResponse_unstable, DefaultsReadRequest_unstable, DefaultsReadResponse_unstable, DefaultsSaveRequest_unstable, DeleteSessionRequest, DeleteSourceRequest_unstable, DictationConfigRequest_unstable, DictationConfigResponse_unstable, DictationDownloadProgress, DictationLocalModelStatus, DictationModelCancelRequest_unstable, DictationModelDeleteRequest_unstable, DictationModelDownloadProgressRequest_unstable, DictationModelDownloadProgressResponse_unstable, DictationModelDownloadRequest_unstable, DictationModelOption, DictationModelSelectRequest_unstable, DictationModelsListRequest_unstable, DictationModelsListResponse_unstable, DictationProviderStatusEntry, DictationSecretDeleteRequest_unstable, DictationSecretSaveRequest_unstable, DictationTranscribeRequest_unstable, DictationTranscribeResponse_unstable, EmbeddedResource, EmbeddedResourceResource, EmptyResponse, ExportSessionRequest_unstable, ExportSessionResponse_unstable, ExportSourceRequest_unstable, ExportSourceResponse_unstable, ExtRequest, ExtResponse, GetExtensionsRequest_unstable, GetExtensionsResponse_unstable, GetSessionExtensionsRequest_unstable, GetSessionExtensionsResponse_unstable, GetToolsRequest_unstable, GetToolsResponse_unstable, GooseToolCallRequest_unstable, GooseToolCallResponse_unstable, ImageContent, ImportSessionRequest_unstable, ImportSessionResponse_unstable, ImportSourcesRequest_unstable, ImportSourcesResponse_unstable, ListProvidersRequest_unstable, ListProvidersResponse_unstable, ListSourcesRequest_unstable, ListSourcesResponse_unstable, OnboardingImportApplyRequest_unstable, OnboardingImportApplyResponse_unstable, OnboardingImportCandidate, OnboardingImportCounts, OnboardingImportScanRequest_unstable, OnboardingImportScanResponse_unstable, OnboardingImportSourceKind, PreferenceKey, PreferencesReadRequest_unstable, PreferencesReadResponse_unstable, PreferencesRemoveRequest_unstable, PreferencesSaveRequest_unstable, PreferenceValue, ProviderCatalogListRequest_unstable, ProviderCatalogListResponse_unstable, ProviderCatalogTemplateRequest_unstable, ProviderCatalogTemplateResponse_unstable, ProviderConfigAuthenticateRequest_unstable, ProviderConfigChangeResponse_unstable, ProviderConfigDeleteRequest_unstable, ProviderConfigFieldUpdate, ProviderConfigFieldValueDto, ProviderConfigKey, ProviderConfigReadRequest_unstable, ProviderConfigReadResponse_unstable, ProviderConfigSaveRequest_unstable, ProviderConfigStatusDto, ProviderConfigStatusRequest_unstable, ProviderConfigStatusResponse_unstable, ProviderInventoryEntryDto, ProviderInventoryModelDto, ProviderSetupCatalogEntryDto, ProviderSetupCatalogListRequest_unstable, ProviderSetupCatalogListResponse_unstable, ProviderSetupCategoryDto, ProviderSetupFieldDto, ProviderSetupGroupDto, ProviderSetupMethodDto, ProviderSupportedModelsListRequest_unstable, ProviderSupportedModelsListResponse_unstable, ProviderTemplateCapabilitiesDto, ProviderTemplateCatalogEntryDto, ProviderTemplateDto, ProviderTemplateModelDto, ReadResourceRequest_unstable, ReadResourceResponse_unstable, RefreshProviderInventoryRequest_unstable, RefreshProviderInventoryResponse_unstable, RefreshProviderInventorySkipDto, RefreshProviderInventorySkipReasonDto, RemoveConfigExtensionRequest_unstable, RemoveExtensionRequest_unstable, RenameSessionRequest_unstable, ResourceLink, Role, SessionSystemPromptMode, SetSessionSystemPromptRequest_unstable, SourceEntry, SourceScope, SourceType, SteerSessionRequest_unstable, SteerSessionResponse_unstable, TextContent, TextResourceContents, ToggleConfigExtensionRequest_unstable, UnarchiveSessionRequest_unstable, UpdateSessionProjectRequest_unstable, UpdateSourceRequest_unstable, UpdateSourceResponse_unstable, UpdateWorkingDirRequest_unstable } from './types.gen.js'; export const GOOSE_EXT_METHODS = [ { @@ -38,6 +38,11 @@ export const GOOSE_EXT_METHODS = [ requestType: "SetSessionSystemPromptRequest_unstable", responseType: "EmptyResponse", }, + { + method: "_goose/unstable/session/steer", + requestType: "SteerSessionRequest_unstable", + responseType: "SteerSessionResponse_unstable", + }, { method: "session/delete", requestType: "DeleteSessionRequest", diff --git a/ui/sdk/src/generated/types.gen.ts b/ui/sdk/src/generated/types.gen.ts index 410f74ada5be..747f66a3e570 100644 --- a/ui/sdk/src/generated/types.gen.ts +++ b/ui/sdk/src/generated/types.gen.ts @@ -109,6 +109,212 @@ export type SetSessionSystemPromptRequest_unstable = { */ export type SessionSystemPromptMode = 'set' | 'append'; +/** + * Add user input to the currently active prompt without starting a new prompt. + */ +export type SteerSessionRequest_unstable = { + sessionId: string; + prompt?: Array; + expectedRunId: string; +}; + +/** + * Content blocks represent displayable information in the Agent Client Protocol. + * + * They provide a structured way to handle various types of user-facing content—whether + * it's text from language models, images for analysis, or embedded resources for context. + * + * Content blocks appear in: + * - User prompts sent via `session/prompt` + * - Language model output streamed through `session/update` notifications + * - Progress updates and results from tool calls + * + * This structure is compatible with the Model Context Protocol (MCP), enabling + * agents to seamlessly forward content from MCP tool outputs without transformation. + * + * See protocol docs: [Content](https://agentclientprotocol.com/protocol/content) + */ +export type ContentBlock = ({ + type: 'TextContent'; +} & TextContent) | ({ + type: 'ImageContent'; +} & ImageContent) | ({ + type: 'AudioContent'; +} & AudioContent) | ({ + type: 'ResourceLink'; +} & ResourceLink) | ({ + type: 'EmbeddedResource'; +} & EmbeddedResource); + +/** + * Optional annotations for the client. The client can use annotations to inform how objects are used or displayed + */ +export type Annotations = { + audience?: Array | null; + lastModified?: string | null; + priority?: number | null; + /** + * 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) + */ + _meta?: { + [key: string]: unknown; + } | null; +}; + +/** + * The sender or recipient of messages and data in a conversation. + */ +export type Role = 'assistant' | 'user'; + +/** + * Text provided to or from an LLM. + */ +export type TextContent = { + annotations?: Annotations | null; + text: string; + /** + * 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) + */ + _meta?: { + [key: string]: unknown; + } | null; +}; + +/** + * An image provided to or from an LLM. + */ +export type ImageContent = { + annotations?: Annotations | null; + data: string; + mimeType: string; + uri?: string | null; + /** + * 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) + */ + _meta?: { + [key: string]: unknown; + } | null; +}; + +/** + * Audio provided to or from an LLM. + */ +export type AudioContent = { + annotations?: Annotations | null; + data: string; + mimeType: string; + /** + * 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) + */ + _meta?: { + [key: string]: unknown; + } | null; +}; + +/** + * A resource that the server is capable of reading, included in a prompt or tool call result. + */ +export type ResourceLink = { + annotations?: Annotations | null; + description?: string | null; + mimeType?: string | null; + name: string; + size?: number | null; + title?: string | null; + uri: string; + /** + * 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) + */ + _meta?: { + [key: string]: unknown; + } | null; +}; + +/** + * Resource content that can be embedded in a message. + */ +export type EmbeddedResourceResource = TextResourceContents | BlobResourceContents; + +/** + * Text-based resource contents. + */ +export type TextResourceContents = { + mimeType?: string | null; + text: string; + uri: string; + /** + * 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) + */ + _meta?: { + [key: string]: unknown; + } | null; +}; + +/** + * Binary resource contents. + */ +export type BlobResourceContents = { + blob: string; + mimeType?: string | null; + uri: string; + /** + * 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) + */ + _meta?: { + [key: string]: unknown; + } | null; +}; + +/** + * The contents of a resource, embedded into a prompt or tool call result. + */ +export type EmbeddedResource = { + annotations?: Annotations | null; + resource: EmbeddedResourceResource; + /** + * 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) + */ + _meta?: { + [key: string]: unknown; + } | null; +}; + +export type SteerSessionResponse_unstable = { + runId: string; +}; + /** * Delete a session. */ @@ -1090,14 +1296,14 @@ export type DictationModelSelectRequest_unstable = { export type ExtRequest = { id: string; method: string; - params?: AddExtensionRequest_unstable | RemoveExtensionRequest_unstable | GetToolsRequest_unstable | GooseToolCallRequest_unstable | ReadResourceRequest_unstable | UpdateWorkingDirRequest_unstable | SetSessionSystemPromptRequest_unstable | DeleteSessionRequest | GetExtensionsRequest_unstable | AddConfigExtensionRequest_unstable | RemoveConfigExtensionRequest_unstable | ToggleConfigExtensionRequest_unstable | GetSessionExtensionsRequest_unstable | ListProvidersRequest_unstable | ProviderSupportedModelsListRequest_unstable | ProviderCatalogListRequest_unstable | ProviderSetupCatalogListRequest_unstable | ProviderCatalogTemplateRequest_unstable | CustomProviderCreateRequest_unstable | CustomProviderReadRequest_unstable | CustomProviderUpdateRequest_unstable | CustomProviderDeleteRequest_unstable | RefreshProviderInventoryRequest_unstable | ProviderConfigReadRequest_unstable | ProviderConfigStatusRequest_unstable | ProviderConfigSaveRequest_unstable | ProviderConfigDeleteRequest_unstable | ProviderConfigAuthenticateRequest_unstable | PreferencesReadRequest_unstable | PreferencesSaveRequest_unstable | PreferencesRemoveRequest_unstable | DefaultsReadRequest_unstable | DefaultsSaveRequest_unstable | OnboardingImportScanRequest_unstable | OnboardingImportApplyRequest_unstable | ExportSessionRequest_unstable | ImportSessionRequest_unstable | UpdateSessionProjectRequest_unstable | RenameSessionRequest_unstable | ArchiveSessionRequest_unstable | UnarchiveSessionRequest_unstable | CreateSourceRequest_unstable | ListSourcesRequest_unstable | UpdateSourceRequest_unstable | DeleteSourceRequest_unstable | ExportSourceRequest_unstable | ImportSourcesRequest_unstable | DictationTranscribeRequest_unstable | DictationConfigRequest_unstable | DictationSecretSaveRequest_unstable | DictationSecretDeleteRequest_unstable | DictationModelsListRequest_unstable | DictationModelDownloadRequest_unstable | DictationModelDownloadProgressRequest_unstable | DictationModelCancelRequest_unstable | DictationModelDeleteRequest_unstable | DictationModelSelectRequest_unstable | { + params?: AddExtensionRequest_unstable | RemoveExtensionRequest_unstable | GetToolsRequest_unstable | GooseToolCallRequest_unstable | ReadResourceRequest_unstable | UpdateWorkingDirRequest_unstable | SetSessionSystemPromptRequest_unstable | SteerSessionRequest_unstable | DeleteSessionRequest | GetExtensionsRequest_unstable | AddConfigExtensionRequest_unstable | RemoveConfigExtensionRequest_unstable | ToggleConfigExtensionRequest_unstable | GetSessionExtensionsRequest_unstable | ListProvidersRequest_unstable | ProviderSupportedModelsListRequest_unstable | ProviderCatalogListRequest_unstable | ProviderSetupCatalogListRequest_unstable | ProviderCatalogTemplateRequest_unstable | CustomProviderCreateRequest_unstable | CustomProviderReadRequest_unstable | CustomProviderUpdateRequest_unstable | CustomProviderDeleteRequest_unstable | RefreshProviderInventoryRequest_unstable | ProviderConfigReadRequest_unstable | ProviderConfigStatusRequest_unstable | ProviderConfigSaveRequest_unstable | ProviderConfigDeleteRequest_unstable | ProviderConfigAuthenticateRequest_unstable | PreferencesReadRequest_unstable | PreferencesSaveRequest_unstable | PreferencesRemoveRequest_unstable | DefaultsReadRequest_unstable | DefaultsSaveRequest_unstable | OnboardingImportScanRequest_unstable | OnboardingImportApplyRequest_unstable | ExportSessionRequest_unstable | ImportSessionRequest_unstable | UpdateSessionProjectRequest_unstable | RenameSessionRequest_unstable | ArchiveSessionRequest_unstable | UnarchiveSessionRequest_unstable | CreateSourceRequest_unstable | ListSourcesRequest_unstable | UpdateSourceRequest_unstable | DeleteSourceRequest_unstable | ExportSourceRequest_unstable | ImportSourcesRequest_unstable | DictationTranscribeRequest_unstable | DictationConfigRequest_unstable | DictationSecretSaveRequest_unstable | DictationSecretDeleteRequest_unstable | DictationModelsListRequest_unstable | DictationModelDownloadRequest_unstable | DictationModelDownloadProgressRequest_unstable | DictationModelCancelRequest_unstable | DictationModelDeleteRequest_unstable | DictationModelSelectRequest_unstable | { [key: string]: unknown; } | null; }; export type ExtResponse = { id: string; - result?: EmptyResponse | GetToolsResponse_unstable | GooseToolCallResponse_unstable | ReadResourceResponse_unstable | GetExtensionsResponse_unstable | GetSessionExtensionsResponse_unstable | ListProvidersResponse_unstable | ProviderSupportedModelsListResponse_unstable | ProviderCatalogListResponse_unstable | ProviderSetupCatalogListResponse_unstable | ProviderCatalogTemplateResponse_unstable | CustomProviderCreateResponse_unstable | CustomProviderReadResponse_unstable | CustomProviderUpdateResponse_unstable | CustomProviderDeleteResponse_unstable | RefreshProviderInventoryResponse_unstable | ProviderConfigReadResponse_unstable | ProviderConfigStatusResponse_unstable | ProviderConfigChangeResponse_unstable | PreferencesReadResponse_unstable | DefaultsReadResponse_unstable | OnboardingImportScanResponse_unstable | OnboardingImportApplyResponse_unstable | ExportSessionResponse_unstable | ImportSessionResponse_unstable | CreateSourceResponse_unstable | ListSourcesResponse_unstable | UpdateSourceResponse_unstable | ExportSourceResponse_unstable | ImportSourcesResponse_unstable | DictationTranscribeResponse_unstable | DictationConfigResponse_unstable | DictationModelsListResponse_unstable | DictationModelDownloadProgressResponse_unstable | unknown; + result?: EmptyResponse | GetToolsResponse_unstable | GooseToolCallResponse_unstable | ReadResourceResponse_unstable | SteerSessionResponse_unstable | GetExtensionsResponse_unstable | GetSessionExtensionsResponse_unstable | ListProvidersResponse_unstable | ProviderSupportedModelsListResponse_unstable | ProviderCatalogListResponse_unstable | ProviderSetupCatalogListResponse_unstable | ProviderCatalogTemplateResponse_unstable | CustomProviderCreateResponse_unstable | CustomProviderReadResponse_unstable | CustomProviderUpdateResponse_unstable | CustomProviderDeleteResponse_unstable | RefreshProviderInventoryResponse_unstable | ProviderConfigReadResponse_unstable | ProviderConfigStatusResponse_unstable | ProviderConfigChangeResponse_unstable | PreferencesReadResponse_unstable | DefaultsReadResponse_unstable | OnboardingImportScanResponse_unstable | OnboardingImportApplyResponse_unstable | ExportSessionResponse_unstable | ImportSessionResponse_unstable | CreateSourceResponse_unstable | ListSourcesResponse_unstable | UpdateSourceResponse_unstable | ExportSourceResponse_unstable | ImportSourcesResponse_unstable | DictationTranscribeResponse_unstable | DictationConfigResponse_unstable | DictationModelsListResponse_unstable | DictationModelDownloadProgressResponse_unstable | unknown; } | { error: { code: number; diff --git a/ui/sdk/src/generated/zod.gen.ts b/ui/sdk/src/generated/zod.gen.ts index efebe29bc0d9..64b03089e433 100644 --- a/ui/sdk/src/generated/zod.gen.ts +++ b/ui/sdk/src/generated/zod.gen.ts @@ -105,6 +105,218 @@ export const zSetSessionSystemPromptRequest_unstable = z.object({ text: z.string() }); +/** + * The sender or recipient of messages and data in a conversation. + */ +export const zRole = z.enum(['assistant', 'user']); + +/** + * Optional annotations for the client. The client can use annotations to inform how objects are used or displayed + */ +export const zAnnotations = z.object({ + audience: z.union([ + z.array(zRole), + z.null() + ]).optional(), + lastModified: z.union([ + z.string(), + z.null() + ]).optional(), + priority: z.union([ + z.number(), + z.null() + ]).optional(), + _meta: z.union([ + z.record(z.unknown()), + z.null() + ]).optional() +}); + +/** + * Text provided to or from an LLM. + */ +export const zTextContent = z.object({ + annotations: z.union([ + zAnnotations, + z.null() + ]).optional(), + text: z.string(), + _meta: z.union([ + z.record(z.unknown()), + z.null() + ]).optional() +}); + +/** + * An image provided to or from an LLM. + */ +export const zImageContent = z.object({ + annotations: z.union([ + zAnnotations, + z.null() + ]).optional(), + data: z.string(), + mimeType: z.string(), + uri: z.union([ + z.string(), + z.null() + ]).optional(), + _meta: z.union([ + z.record(z.unknown()), + z.null() + ]).optional() +}); + +/** + * Audio provided to or from an LLM. + */ +export const zAudioContent = z.object({ + annotations: z.union([ + zAnnotations, + z.null() + ]).optional(), + data: z.string(), + mimeType: z.string(), + _meta: z.union([ + z.record(z.unknown()), + z.null() + ]).optional() +}); + +/** + * A resource that the server is capable of reading, included in a prompt or tool call result. + */ +export const zResourceLink = z.object({ + annotations: z.union([ + zAnnotations, + z.null() + ]).optional(), + description: z.union([ + z.string(), + z.null() + ]).optional(), + mimeType: z.union([ + z.string(), + z.null() + ]).optional(), + name: z.string(), + size: z.union([ + z.coerce.bigint().min(BigInt('-9223372036854775808'), { message: 'Invalid value: Expected int64 to be >= -9223372036854775808' }).max(BigInt('9223372036854775807'), { message: 'Invalid value: Expected int64 to be <= 9223372036854775807' }), + z.null() + ]).optional(), + title: z.union([ + z.string(), + z.null() + ]).optional(), + uri: z.string(), + _meta: z.union([ + z.record(z.unknown()), + z.null() + ]).optional() +}); + +/** + * Text-based resource contents. + */ +export const zTextResourceContents = z.object({ + mimeType: z.union([ + z.string(), + z.null() + ]).optional(), + text: z.string(), + uri: z.string(), + _meta: z.union([ + z.record(z.unknown()), + z.null() + ]).optional() +}); + +/** + * Binary resource contents. + */ +export const zBlobResourceContents = z.object({ + blob: z.string(), + mimeType: z.union([ + z.string(), + z.null() + ]).optional(), + uri: z.string(), + _meta: z.union([ + z.record(z.unknown()), + z.null() + ]).optional() +}); + +/** + * Resource content that can be embedded in a message. + */ +export const zEmbeddedResourceResource = z.union([ + zTextResourceContents, + zBlobResourceContents +]); + +/** + * The contents of a resource, embedded into a prompt or tool call result. + */ +export const zEmbeddedResource = z.object({ + annotations: z.union([ + zAnnotations, + z.null() + ]).optional(), + resource: zEmbeddedResourceResource, + _meta: z.union([ + z.record(z.unknown()), + z.null() + ]).optional() +}); + +/** + * Content blocks represent displayable information in the Agent Client Protocol. + * + * They provide a structured way to handle various types of user-facing content—whether + * it's text from language models, images for analysis, or embedded resources for context. + * + * Content blocks appear in: + * - User prompts sent via `session/prompt` + * - Language model output streamed through `session/update` notifications + * - Progress updates and results from tool calls + * + * This structure is compatible with the Model Context Protocol (MCP), enabling + * agents to seamlessly forward content from MCP tool outputs without transformation. + * + * See protocol docs: [Content](https://agentclientprotocol.com/protocol/content) + */ +export const zContentBlock = z.union([ + z.object({ + type: z.literal('TextContent') + }).and(zTextContent), + z.object({ + type: z.literal('ImageContent') + }).and(zImageContent), + z.object({ + type: z.literal('AudioContent') + }).and(zAudioContent), + z.object({ + type: z.literal('ResourceLink') + }).and(zResourceLink), + z.object({ + type: z.literal('EmbeddedResource') + }).and(zEmbeddedResource) +]); + +/** + * Add user input to the currently active prompt without starting a new prompt. + */ +export const zSteerSessionRequest_unstable = z.object({ + sessionId: z.string(), + prompt: z.array(zContentBlock).optional().default([]), + expectedRunId: z.string() +}); + +export const zSteerSessionResponse_unstable = z.object({ + runId: z.string() +}); + /** * Delete a session. */ @@ -1100,6 +1312,7 @@ export const zExtRequest = z.object({ zReadResourceRequest_unstable, zUpdateWorkingDirRequest_unstable, zSetSessionSystemPromptRequest_unstable, + zSteerSessionRequest_unstable, zDeleteSessionRequest, zGetExtensionsRequest_unstable, zAddConfigExtensionRequest_unstable, @@ -1167,6 +1380,7 @@ export const zExtResponse = z.union([ zGetToolsResponse_unstable, zGooseToolCallResponse_unstable, zReadResourceResponse_unstable, + zSteerSessionResponse_unstable, zGetExtensionsResponse_unstable, zGetSessionExtensionsResponse_unstable, zListProvidersResponse_unstable, From f71cbae4e649a8e923f273f82b7150b5606d2bd7 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Fri, 5 Jun 2026 09:06:40 +1000 Subject: [PATCH 02/10] acp: return structured run id data on steer mismatch When a steer request's expected_run_id does not match the active run, include expectedRunId and actualRunId in the error data so clients can programmatically retry against the correct run instead of string-parsing the message. Signed-off-by: Michael Neale --- crates/goose/src/acp/server.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/goose/src/acp/server.rs b/crates/goose/src/acp/server.rs index 8422c8f3715f..fa26998b80cb 100644 --- a/crates/goose/src/acp/server.rs +++ b/crates/goose/src/acp/server.rs @@ -2355,9 +2355,15 @@ impl GooseAcpAgent { agent_client_protocol::Error::invalid_params().data("no active run to steer") })?; if active_run_id != expected_run_id { - return Err(agent_client_protocol::Error::invalid_params().data(format!( - "expected active run id `{expected_run_id}` but found `{active_run_id}`" - ))); + return Err( + agent_client_protocol::Error::invalid_params().data(serde_json::json!({ + "message": format!( + "expected active run id `{expected_run_id}` but found `{active_run_id}`" + ), + "expectedRunId": expected_run_id, + "actualRunId": active_run_id, + })), + ); } Ok(active_run_id.clone()) } From 10580ee56de69e36bee393a10ad25c1ccc5eb2ab Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Fri, 5 Jun 2026 09:15:30 +1000 Subject: [PATCH 03/10] acp: regenerate SDK types after merge Signed-off-by: Michael Neale --- ui/sdk/src/generated/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/sdk/src/generated/index.ts b/ui/sdk/src/generated/index.ts index c8e5a9b5b19d..d44daf55706b 100644 --- a/ui/sdk/src/generated/index.ts +++ b/ui/sdk/src/generated/index.ts @@ -1,6 +1,6 @@ // This file is auto-generated by @hey-api/openapi-ts -export type { AddConfigExtensionRequest_unstable, AddExtensionRequest_unstable, Annotations, ArchiveSessionRequest_unstable, AudioContent, BlobResourceContents, ContentBlock, CreateSourceRequest_unstable, CreateSourceResponse_unstable, CustomProviderConfigDto, CustomProviderCreateRequest_unstable, CustomProviderCreateResponse_unstable, CustomProviderDeleteRequest_unstable, CustomProviderDeleteResponse_unstable, CustomProviderReadRequest_unstable, CustomProviderReadResponse_unstable, CustomProviderUpdateRequest_unstable, CustomProviderUpdateResponse_unstable, DefaultsReadRequest_unstable, DefaultsReadResponse_unstable, DefaultsSaveRequest_unstable, DeleteSessionRequest, DeleteSourceRequest_unstable, DictationConfigRequest_unstable, DictationConfigResponse_unstable, DictationDownloadProgress, DictationLocalModelStatus, DictationModelCancelRequest_unstable, DictationModelDeleteRequest_unstable, DictationModelDownloadProgressRequest_unstable, DictationModelDownloadProgressResponse_unstable, DictationModelDownloadRequest_unstable, DictationModelOption, DictationModelSelectRequest_unstable, DictationModelsListRequest_unstable, DictationModelsListResponse_unstable, DictationProviderStatusEntry, DictationSecretDeleteRequest_unstable, DictationSecretSaveRequest_unstable, DictationTranscribeRequest_unstable, DictationTranscribeResponse_unstable, ElicitationRespondRequest_unstable, EmbeddedResource, EmbeddedResourceResource, EmptyResponse, ExportSessionRequest_unstable, ExportSessionResponse_unstable, ExportSourceRequest_unstable, ExportSourceResponse_unstable, ExtNotification, ExtRequest, ExtResponse, GetExtensionsRequest_unstable, GetExtensionsResponse_unstable, GetSessionExtensionsRequest_unstable, GetSessionExtensionsResponse_unstable, GetToolsRequest_unstable, GetToolsResponse_unstable, GooseSessionNotification_unstable, GooseSessionUpdate, GooseToolCallRequest_unstable, GooseToolCallResponse_unstable, ImageContent, ImportSessionRequest_unstable, ImportSessionResponse_unstable, ImportSourcesRequest_unstable, ImportSourcesResponse_unstable, Interaction, InteractionState, InteractionUpdate, ListProvidersRequest_unstable, ListProvidersResponse_unstable, ListSourcesRequest_unstable, ListSourcesResponse_unstable, OnboardingImportApplyRequest_unstable, OnboardingImportApplyResponse_unstable, OnboardingImportCandidate, OnboardingImportCounts, OnboardingImportScanRequest_unstable, OnboardingImportScanResponse_unstable, OnboardingImportSourceKind, PreferenceKey, PreferenceValue, PreferencesReadRequest_unstable, PreferencesReadResponse_unstable, PreferencesRemoveRequest_unstable, PreferencesSaveRequest_unstable, ProviderCatalogListRequest_unstable, ProviderCatalogListResponse_unstable, ProviderCatalogTemplateRequest_unstable, ProviderCatalogTemplateResponse_unstable, ProviderConfigAuthenticateRequest_unstable, ProviderConfigChangeResponse_unstable, ProviderConfigDeleteRequest_unstable, ProviderConfigFieldUpdate, ProviderConfigFieldValueDto, ProviderConfigKey, ProviderConfigReadRequest_unstable, ProviderConfigReadResponse_unstable, ProviderConfigSaveRequest_unstable, ProviderConfigStatusDto, ProviderConfigStatusRequest_unstable, ProviderConfigStatusResponse_unstable, ProviderInventoryEntryDto, ProviderInventoryModelDto, ProviderSetupCatalogEntryDto, ProviderSetupCatalogListRequest_unstable, ProviderSetupCatalogListResponse_unstable, ProviderSetupCategoryDto, ProviderSetupFieldDto, ProviderSetupGroupDto, ProviderSetupMethodDto, ProviderSupportedModelsListRequest_unstable, ProviderSupportedModelsListResponse_unstable, ProviderTemplateCapabilitiesDto, ProviderTemplateCatalogEntryDto, ProviderTemplateDto, ProviderTemplateModelDto, ReadResourceRequest_unstable, ReadResourceResponse_unstable, RefreshProviderInventoryRequest_unstable, RefreshProviderInventoryResponse_unstable, RefreshProviderInventorySkipDto, RefreshProviderInventorySkipReasonDto, RemoveConfigExtensionRequest_unstable, RemoveExtensionRequest_unstable, RenameSessionRequest_unstable, ResourceLink, Role, SessionSystemPromptMode, SessionUsageUpdate, SetSessionSystemPromptRequest_unstable, SourceEntry, SourceScope, SourceType, StatusMessage, StatusMessageUpdate, SteerSessionRequest_unstable, SteerSessionResponse_unstable, TextContent, TextResourceContents, ToggleConfigExtensionRequest_unstable, UnarchiveSessionRequest_unstable, UpdateSessionProjectRequest_unstable, UpdateSourceRequest_unstable, UpdateSourceResponse_unstable, UpdateWorkingDirRequest_unstable } from './types.gen.js'; +export type { AddConfigExtensionRequest_unstable, AddExtensionRequest_unstable, Annotations, ArchiveSessionRequest_unstable, AudioContent, BlobResourceContents, ContentBlock, CreateSourceRequest_unstable, CreateSourceResponse_unstable, CustomProviderConfigDto, CustomProviderCreateRequest_unstable, CustomProviderCreateResponse_unstable, CustomProviderDeleteRequest_unstable, CustomProviderDeleteResponse_unstable, CustomProviderReadRequest_unstable, CustomProviderReadResponse_unstable, CustomProviderUpdateRequest_unstable, CustomProviderUpdateResponse_unstable, DefaultsReadRequest_unstable, DefaultsReadResponse_unstable, DefaultsSaveRequest_unstable, DeleteSessionRequest, DeleteSourceRequest_unstable, DictationConfigRequest_unstable, DictationConfigResponse_unstable, DictationDownloadProgress, DictationLocalModelStatus, DictationModelCancelRequest_unstable, DictationModelDeleteRequest_unstable, DictationModelDownloadProgressRequest_unstable, DictationModelDownloadProgressResponse_unstable, DictationModelDownloadRequest_unstable, DictationModelOption, DictationModelSelectRequest_unstable, DictationModelsListRequest_unstable, DictationModelsListResponse_unstable, DictationProviderStatusEntry, DictationSecretDeleteRequest_unstable, DictationSecretSaveRequest_unstable, DictationTranscribeRequest_unstable, DictationTranscribeResponse_unstable, ElicitationRespondRequest_unstable, EmbeddedResource, EmbeddedResourceResource, EmptyResponse, ExportSessionRequest_unstable, ExportSessionResponse_unstable, ExportSourceRequest_unstable, ExportSourceResponse_unstable, ExtNotification, ExtRequest, ExtResponse, GetExtensionsRequest_unstable, GetExtensionsResponse_unstable, GetSessionExtensionsRequest_unstable, GetSessionExtensionsResponse_unstable, GetToolsRequest_unstable, GetToolsResponse_unstable, GooseSessionNotification_unstable, GooseSessionUpdate, GooseToolCallRequest_unstable, GooseToolCallResponse_unstable, ImageContent, ImportSessionRequest_unstable, ImportSessionResponse_unstable, ImportSourcesRequest_unstable, ImportSourcesResponse_unstable, Interaction, InteractionState, InteractionUpdate, ListProvidersRequest_unstable, ListProvidersResponse_unstable, ListSourcesRequest_unstable, ListSourcesResponse_unstable, OnboardingImportApplyRequest_unstable, OnboardingImportApplyResponse_unstable, OnboardingImportCandidate, OnboardingImportCounts, OnboardingImportScanRequest_unstable, OnboardingImportScanResponse_unstable, OnboardingImportSourceKind, PreferenceKey, PreferencesReadRequest_unstable, PreferencesReadResponse_unstable, PreferencesRemoveRequest_unstable, PreferencesSaveRequest_unstable, PreferenceValue, ProviderCatalogListRequest_unstable, ProviderCatalogListResponse_unstable, ProviderCatalogTemplateRequest_unstable, ProviderCatalogTemplateResponse_unstable, ProviderConfigAuthenticateRequest_unstable, ProviderConfigChangeResponse_unstable, ProviderConfigDeleteRequest_unstable, ProviderConfigFieldUpdate, ProviderConfigFieldValueDto, ProviderConfigKey, ProviderConfigReadRequest_unstable, ProviderConfigReadResponse_unstable, ProviderConfigSaveRequest_unstable, ProviderConfigStatusDto, ProviderConfigStatusRequest_unstable, ProviderConfigStatusResponse_unstable, ProviderInventoryEntryDto, ProviderInventoryModelDto, ProviderSetupCatalogEntryDto, ProviderSetupCatalogListRequest_unstable, ProviderSetupCatalogListResponse_unstable, ProviderSetupCategoryDto, ProviderSetupFieldDto, ProviderSetupGroupDto, ProviderSetupMethodDto, ProviderSupportedModelsListRequest_unstable, ProviderSupportedModelsListResponse_unstable, ProviderTemplateCapabilitiesDto, ProviderTemplateCatalogEntryDto, ProviderTemplateDto, ProviderTemplateModelDto, ReadResourceRequest_unstable, ReadResourceResponse_unstable, RefreshProviderInventoryRequest_unstable, RefreshProviderInventoryResponse_unstable, RefreshProviderInventorySkipDto, RefreshProviderInventorySkipReasonDto, RemoveConfigExtensionRequest_unstable, RemoveExtensionRequest_unstable, RenameSessionRequest_unstable, ResourceLink, Role, SessionSystemPromptMode, SessionUsageUpdate, SetSessionSystemPromptRequest_unstable, SourceEntry, SourceScope, SourceType, StatusMessage, StatusMessageUpdate, SteerSessionRequest_unstable, SteerSessionResponse_unstable, TextContent, TextResourceContents, ToggleConfigExtensionRequest_unstable, UnarchiveSessionRequest_unstable, UpdateSessionProjectRequest_unstable, UpdateSourceRequest_unstable, UpdateSourceResponse_unstable, UpdateWorkingDirRequest_unstable } from './types.gen.js'; export const GOOSE_EXT_METHODS = [ { From 6e4a1f721f6f33f5fd2b8fa80f064991fc66b67f Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Fri, 5 Jun 2026 11:13:30 +1000 Subject: [PATCH 04/10] acp: ignore activeRunId session-info updates in test notification fixtures The merge with main introduced a session-naming feature that uses SessionInfoUpdate. The steering active-run notifications also reuse SessionInfoUpdate (carrying meta.goose.activeRunId), which the common test fixture mapped into Notification::SessionInfoUpdate, polluting the expected notification streams in acp_provider_test. Filter out activeRunId-only updates in to_notifications so they are treated as a goose-internal run-id signal rather than a session-info notification. Signed-off-by: Michael Neale --- crates/goose/tests/acp_fixtures/mod.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/goose/tests/acp_fixtures/mod.rs b/crates/goose/tests/acp_fixtures/mod.rs index 55370be5dbd0..fd5677da7572 100644 --- a/crates/goose/tests/acp_fixtures/mod.rs +++ b/crates/goose/tests/acp_fixtures/mod.rs @@ -302,6 +302,13 @@ pub fn to_notifications(updates: &[SessionUpdate]) -> Vec { SessionUpdate::ConfigOptionUpdate(_) => out.push(Notification::ConfigOption), SessionUpdate::SessionInfoUpdate(update) => { let meta = update.meta.as_ref(); + let is_active_run_update = meta + .and_then(|m| m.get("goose")) + .and_then(|g| g.get("activeRunId")) + .is_some(); + if is_active_run_update { + continue; + } out.push(Notification::SessionInfoUpdate { title: update.title.value().cloned(), updated_at: update.updated_at.value().cloned(), From 50424fbd46e8425cf85e8453fd8214189edf9cb5 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Tue, 9 Jun 2026 11:33:51 +1000 Subject: [PATCH 05/10] adding a steer boolean so fields can be identified --- .../src/conversation/message.rs | 16 +++++++++ crates/goose/src/acp/server.rs | 19 +++++++--- crates/goose/src/agents/agent.rs | 2 +- .../goose/tests/acp_custom_requests_test.rs | 35 ++++++++++++++++++- ui/desktop/openapi.json | 4 +++ 5 files changed, 69 insertions(+), 7 deletions(-) diff --git a/crates/goose-providers/src/conversation/message.rs b/crates/goose-providers/src/conversation/message.rs index a89d4760b1e5..5b0a1d047dc7 100644 --- a/crates/goose-providers/src/conversation/message.rs +++ b/crates/goose-providers/src/conversation/message.rs @@ -667,6 +667,11 @@ pub struct MessageMetadata { pub agent_visible: bool, #[serde(skip_serializing_if = "Option::is_none")] pub inference: Option, + /// Whether this message is a steer injected into an active run. UI-only: + /// surfaced as `_meta.goose.steer` so clients can mark the steer boundary + /// without matching user-visible text. Never sent to providers. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub steer: bool, } impl Default for MessageMetadata { @@ -675,6 +680,7 @@ impl Default for MessageMetadata { user_visible: true, agent_visible: true, inference: None, + steer: false, } } } @@ -743,6 +749,11 @@ impl MessageMetadata { self.inference = Some(inference); self } + + pub fn with_steer(mut self) -> Self { + self.steer = true; + self + } } #[derive(ToSchema, Clone, PartialEq, Serialize, Deserialize, Debug)] @@ -1028,6 +1039,11 @@ impl Message { self } + pub fn with_steer(mut self) -> Self { + self.metadata.steer = true; + self + } + pub fn with_inference_if_assistant(self, inference: InferenceMetadata) -> Self { if self.role == Role::Assistant && self.metadata.inference.is_none() { self.with_inference(inference) diff --git a/crates/goose/src/acp/server.rs b/crates/goose/src/acp/server.rs index ab0a75e6d6ba..6cd9e90a2575 100644 --- a/crates/goose/src/acp/server.rs +++ b/crates/goose/src/acp/server.rs @@ -1412,6 +1412,7 @@ impl GooseAcpAgent { session_id_str: &str, message_id: Option<&str>, message_created: i64, + steer: bool, agent: &Arc, session: &mut GooseAcpSession, cx: &ConnectionTo, @@ -1422,7 +1423,7 @@ impl GooseAcpAgent { session_id.clone(), SessionUpdate::AgentMessageChunk( ContentChunk::new(ContentBlock::Text(TextContent::new(text.text.clone()))) - .meta(message_update_meta(message_id, message_created)), + .meta(message_update_meta(message_id, message_created, steer)), ), ))?; } @@ -1455,7 +1456,11 @@ impl GooseAcpAgent { ContentChunk::new(ContentBlock::Text(TextContent::new( thinking.thinking.clone(), ))) - .meta(message_update_meta(message_id, message_created)), + .meta(message_update_meta( + message_id, + message_created, + steer, + )), ), ))?; } @@ -2121,15 +2126,18 @@ fn send_elicitation_interaction_update( } fn interaction_update_meta(message_id: Option<&str>, created: i64) -> serde_json::Value { - serde_json::Value::Object(message_update_meta(message_id, created)) + serde_json::Value::Object(message_update_meta(message_id, created, false)) } -fn message_update_meta(message_id: Option<&str>, created: i64) -> Meta { +fn message_update_meta(message_id: Option<&str>, created: i64, steer: bool) -> Meta { let mut goose = serde_json::Map::new(); goose.insert("created".to_string(), serde_json::json!(created)); if let Some(id) = message_id { goose.insert("messageId".to_string(), serde_json::json!(id)); } + if steer { + goose.insert("steer".to_string(), serde_json::json!(true)); + } let mut meta = serde_json::Map::new(); meta.insert("goose".to_string(), serde_json::Value::Object(goose)); @@ -2636,6 +2644,7 @@ impl GooseAcpAgent { &session_id, stored_message_id.as_deref(), message.created, + message.metadata.steer, &agent, session, cx, @@ -3747,7 +3756,7 @@ print(\"hello, world\") #[test] fn test_message_update_meta_includes_created_and_message_id() { - let meta = message_update_meta(Some("msg_live"), 1_700_000_000); + let meta = message_update_meta(Some("msg_live"), 1_700_000_000, false); assert_eq!( meta.get("goose"), diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 321d38547f46..e16d557e1870 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -426,7 +426,7 @@ impl Agent { .lock() .await .remove(session_id) - .map(|messages| messages.into_iter().collect()) + .map(|messages| messages.into_iter().map(Message::with_steer).collect()) .unwrap_or_default() } diff --git a/crates/goose/tests/acp_custom_requests_test.rs b/crates/goose/tests/acp_custom_requests_test.rs index dd3f305cc848..53819383652f 100644 --- a/crates/goose/tests/acp_custom_requests_test.rs +++ b/crates/goose/tests/acp_custom_requests_test.rs @@ -114,6 +114,28 @@ fn active_run_id_from_update(update: &SessionUpdate) -> Option { .map(ToString::to_string) } +fn steer_chunk_texts(updates: &[SessionUpdate]) -> Vec { + updates + .iter() + .filter_map(|update| { + let SessionUpdate::AgentMessageChunk(chunk) = update else { + return None; + }; + let ContentBlock::Text(text) = &chunk.content else { + return None; + }; + let is_steer = chunk + .meta + .as_ref() + .and_then(|m| m.get("goose")) + .and_then(|g| g.get("steer")) + .and_then(|s| s.as_bool()) + .unwrap_or(false); + is_steer.then(|| text.text.clone()) + }) + .collect() +} + fn collect_agent_text(updates: &[SessionUpdate]) -> String { updates .iter() @@ -396,11 +418,22 @@ fn test_steer_session_adds_input_to_active_prompt() { assert_eq!(response.stop_reason, StopReason::EndTurn); assert!(steer_sent, "test never observed an active run id"); - let agent_text = collect_agent_text(&session.session_updates()); + let updates = session.session_updates(); + let agent_text = collect_agent_text(&updates); assert!( agent_text.contains("saw steer"), "expected provider to receive steered input, got: {agent_text:?}" ); + + // The echoed steer prompt must be marked structurally so the client + // can locate the boundary without matching user-visible text. + let steer_chunks = steer_chunk_texts(&updates); + assert!( + steer_chunks + .iter() + .any(|t| t.contains("steer while active")), + "expected a chunk marked _meta.goose.steer with the steer text, got: {steer_chunks:?}" + ); }); } diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 3bd3b51992e8..dce8ef10601a 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -6571,6 +6571,10 @@ ], "nullable": true }, + "steer": { + "type": "boolean", + "description": "Whether this message is a steer injected into an active run. UI-only:\nsurfaced as `_meta.goose.steer` so clients can mark the steer boundary\nwithout matching user-visible text. Never sent to providers." + }, "userVisible": { "type": "boolean", "description": "Whether the message should be visible to the user in the UI" From be0572cce88ecb382c77a3fcd98a202847127784 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Tue, 9 Jun 2026 11:47:28 +1000 Subject: [PATCH 06/10] fix(acp): discard orphaned pending steers when active run is cleared A steer enqueued during a run that is then cancelled survived in pending_steers and would be drained into the next unrelated prompt (now flagged steer=true). clear_active_run now discards the session's pending steers when it clears the run. Signed-off-by: Michael Neale --- crates/goose/src/acp/server.rs | 18 ++++++++++++------ crates/goose/src/agents/agent.rs | 23 +++++++++++++++++++++++ 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/crates/goose/src/acp/server.rs b/crates/goose/src/acp/server.rs index 6cd9e90a2575..18c9e3a4870a 100644 --- a/crates/goose/src/acp/server.rs +++ b/crates/goose/src/acp/server.rs @@ -2366,13 +2366,19 @@ impl GooseAcpAgent { } async fn clear_active_run(&self, session_id: &str, run_id: &str) { - let mut sessions = self.sessions.lock().await; - if let Some(session) = sessions.get_mut(session_id) { - if session.active_run_id.as_deref() == Some(run_id) { - session.cancel_token = None; - session.active_run_id = None; + let agent = { + let mut sessions = self.sessions.lock().await; + let Some(session) = sessions.get_mut(session_id) else { + return; + }; + if session.active_run_id.as_deref() != Some(run_id) { + return; } - } + session.cancel_token = None; + session.active_run_id = None; + session.agent.clone() + }; + agent.discard_pending_steers(session_id).await; } async fn require_active_run( diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index e16d557e1870..b3cab77c778b 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -413,6 +413,10 @@ impl Agent { .push_back(message); } + pub async fn discard_pending_steers(&self, session_id: &str) { + self.pending_steers.lock().await.remove(session_id); + } + async fn has_pending_steers(&self, session_id: &str) -> bool { self.pending_steers .lock() @@ -3421,6 +3425,25 @@ exit 0 Ok(()) } + #[tokio::test] + async fn discard_pending_steers_clears_queued_messages() { + let agent = Agent::new(); + let session_id = "session-discard"; + + agent + .steer(session_id, Message::user().with_text("queued steer")) + .await; + assert!(agent.has_pending_steers(session_id).await); + + agent.discard_pending_steers(session_id).await; + + assert!( + !agent.has_pending_steers(session_id).await, + "discarding must drop steers orphaned by a cancelled run so they cannot leak into a later prompt" + ); + assert!(agent.drain_pending_steers(session_id).await.is_empty()); + } + #[test] fn categorize_tool_recognizes_conventional_names() { assert_eq!(categorize_tool("developer__shell"), ToolCategory::Shell); From 0f1deae13a3f3f24ef79063a22916136b409b9cf Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Tue, 9 Jun 2026 12:06:04 +1000 Subject: [PATCH 07/10] chore: regenerate desktop API types for steer field Regenerated ui/desktop/src/api/types.gen.ts to match the steer field added to MessageMetadata in openapi.json. Fixes the out-of-date schema CI check. Signed-off-by: Michael Neale --- ui/desktop/src/api/types.gen.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index fda990138938..426dc28d3f54 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -770,6 +770,12 @@ export type MessageMetadata = { */ agentVisible: boolean; inference?: InferenceMetadata | null; + /** + * Whether this message is a steer injected into an active run. UI-only: + * surfaced as `_meta.goose.steer` so clients can mark the steer boundary + * without matching user-visible text. Never sent to providers. + */ + steer?: boolean; /** * Whether the message should be visible to the user in the UI */ From 59f69784e97e669ea0896992bf0c20baf6964447 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Tue, 9 Jun 2026 12:29:43 +1000 Subject: [PATCH 08/10] fix(acp): carry steer marker through replay path The live stream emitted _meta.goose.steer but the replay path (replay_message_goose_meta) dropped it, so the steer boundary vanished on session reload/refresh and clients fell back to text matching. Replay now emits the marker, symmetric with the live path. Signed-off-by: Michael Neale --- crates/goose/src/acp/server.rs | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/crates/goose/src/acp/server.rs b/crates/goose/src/acp/server.rs index 18c9e3a4870a..0edc380e74c3 100644 --- a/crates/goose/src/acp/server.rs +++ b/crates/goose/src/acp/server.rs @@ -2174,6 +2174,9 @@ fn replay_message_goose_meta(message: &Message) -> serde_json::Map Date: Tue, 9 Jun 2026 16:10:13 +1000 Subject: [PATCH 09/10] fix(acp): route live steered user messages as user chunks The live stream emitted every message's text as an AgentMessageChunk regardless of role, so a steered user message rendered as assistant text live but flipped to a UserMessageChunk on replay (which routes by role). Route the live text arm by role too, making live and replay symmetric; the steer marker now rides on a user chunk in both paths. Signed-off-by: Michael Neale --- crates/goose/src/acp/server.rs | 17 ++++++++++------- crates/goose/tests/acp_custom_requests_test.rs | 5 ++++- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/crates/goose/src/acp/server.rs b/crates/goose/src/acp/server.rs index 0edc380e74c3..2a7af81e98ca 100644 --- a/crates/goose/src/acp/server.rs +++ b/crates/goose/src/acp/server.rs @@ -1412,6 +1412,7 @@ impl GooseAcpAgent { session_id_str: &str, message_id: Option<&str>, message_created: i64, + role: &Role, steer: bool, agent: &Arc, session: &mut GooseAcpSession, @@ -1419,13 +1420,14 @@ impl GooseAcpAgent { ) -> Result<(), agent_client_protocol::Error> { match content_item { MessageContent::Text(text) => { - cx.send_notification(SessionNotification::new( - session_id.clone(), - SessionUpdate::AgentMessageChunk( - ContentChunk::new(ContentBlock::Text(TextContent::new(text.text.clone()))) - .meta(message_update_meta(message_id, message_created, steer)), - ), - ))?; + let chunk = + ContentChunk::new(ContentBlock::Text(TextContent::new(text.text.clone()))) + .meta(message_update_meta(message_id, message_created, steer)); + let update = match role { + Role::User => SessionUpdate::UserMessageChunk(chunk), + Role::Assistant => SessionUpdate::AgentMessageChunk(chunk), + }; + cx.send_notification(SessionNotification::new(session_id.clone(), update))?; } MessageContent::ToolRequest(tool_request) => { self.handle_tool_request( @@ -2653,6 +2655,7 @@ impl GooseAcpAgent { &session_id, stored_message_id.as_deref(), message.created, + &message.role, message.metadata.steer, &agent, session, diff --git a/crates/goose/tests/acp_custom_requests_test.rs b/crates/goose/tests/acp_custom_requests_test.rs index 53819383652f..600a6d94d0ad 100644 --- a/crates/goose/tests/acp_custom_requests_test.rs +++ b/crates/goose/tests/acp_custom_requests_test.rs @@ -118,7 +118,10 @@ fn steer_chunk_texts(updates: &[SessionUpdate]) -> Vec { updates .iter() .filter_map(|update| { - let SessionUpdate::AgentMessageChunk(chunk) = update else { + // A steered message is a user message injected mid-run, so it must + // arrive as a UserMessageChunk (matching the replay path), never an + // AgentMessageChunk. + let SessionUpdate::UserMessageChunk(chunk) = update else { return None; }; let ContentBlock::Text(text) = &chunk.content else { From ea495abffe29d89aa84a893b3e814d0e3289b0af Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Tue, 9 Jun 2026 20:22:47 +1000 Subject: [PATCH 10/10] feat(acp): surface queued-steer status so UIs can show pending steers Previously a steer was only observable once the agent picked it up (the streamed UserMessageChunk with _meta.goose.steer). Between session/steer returning and pickup, the queued message was invisible, so a UI could not show it as pending. Now on_steer_session assigns a stable message id, emits a SessionInfo Update carrying _meta.goose.queuedSteer { messageId, runId }, and returns that id in SteerSessionResponse.messageId. The same id is stamped on the picked-up UserMessageChunk, letting clients correlate queued -> sent. Signed-off-by: Michael Neale --- crates/goose-sdk-types/src/custom_requests.rs | 4 ++ crates/goose/acp-schema.json | 7 ++- crates/goose/src/acp/server.rs | 36 +++++++++++++ .../goose/tests/acp_custom_requests_test.rs | 54 +++++++++++++++++++ ui/sdk/src/generated/types.gen.ts | 6 +++ ui/sdk/src/generated/zod.gen.ts | 3 +- 6 files changed, 108 insertions(+), 2 deletions(-) diff --git a/crates/goose-sdk-types/src/custom_requests.rs b/crates/goose-sdk-types/src/custom_requests.rs index 0ad43e90711f..3e99ff21ac4c 100644 --- a/crates/goose-sdk-types/src/custom_requests.rs +++ b/crates/goose-sdk-types/src/custom_requests.rs @@ -158,6 +158,10 @@ pub struct SteerSessionRequest { #[serde(rename_all = "camelCase")] pub struct SteerSessionResponse { pub run_id: String, + /// Stable id of the queued steer message. The same id later appears as + /// `messageId` on the streamed `UserMessageChunk` (with `_meta.goose.steer`), + /// letting clients correlate a queued steer with its pickup. + pub message_id: String, } /// Delete a session. diff --git a/crates/goose/acp-schema.json b/crates/goose/acp-schema.json index 7afdd8a65901..d8b5c23eca1e 100644 --- a/crates/goose/acp-schema.json +++ b/crates/goose/acp-schema.json @@ -625,10 +625,15 @@ "properties": { "runId": { "type": "string" + }, + "messageId": { + "type": "string", + "description": "Stable id of the queued steer message. The same id later appears as\n`messageId` on the streamed `UserMessageChunk` (with `_meta.goose.steer`),\nletting clients correlate a queued steer with its pickup." } }, "required": [ - "runId" + "runId", + "messageId" ], "x-side": "agent", "x-method": "_goose/unstable/session/steer" diff --git a/crates/goose/src/acp/server.rs b/crates/goose/src/acp/server.rs index 2a7af81e98ca..1445b428657d 100644 --- a/crates/goose/src/acp/server.rs +++ b/crates/goose/src/acp/server.rs @@ -2445,6 +2445,29 @@ impl GooseAcpAgent { )) } + fn send_queued_steer_update( + cx: &ConnectionTo, + session_id: &SessionId, + message_id: &str, + run_id: &str, + ) -> Result<(), agent_client_protocol::Error> { + let mut goose = serde_json::Map::new(); + goose.insert( + "queuedSteer".to_string(), + serde_json::json!({ + "messageId": message_id, + "runId": run_id, + }), + ); + let mut meta = serde_json::Map::new(); + meta.insert("goose".to_string(), serde_json::Value::Object(goose)); + + cx.send_notification(SessionNotification::new( + session_id.clone(), + SessionUpdate::SessionInfoUpdate(SessionInfoUpdate::new().meta(meta)), + )) + } + #[allow(dead_code)] async fn add_mcp_extensions( agent: &Arc, @@ -2759,10 +2782,23 @@ impl GooseAcpAgent { return Err(agent_client_protocol::Error::invalid_params() .data("prompt must contain steerable content")); } + + let message_id = format!("steer_{}", Uuid::new_v4()); + let message = message.with_id(message_id.clone()); agent.steer(&req.session_id, message).await; + if let Some(cx) = self.client_cx.get() { + let _ = Self::send_queued_steer_update( + cx, + &SessionId::new(req.session_id.clone()), + &message_id, + &active_run_id, + ); + } + Ok(SteerSessionResponse { run_id: active_run_id, + message_id, }) } diff --git a/crates/goose/tests/acp_custom_requests_test.rs b/crates/goose/tests/acp_custom_requests_test.rs index 600a6d94d0ad..96d7efe907cf 100644 --- a/crates/goose/tests/acp_custom_requests_test.rs +++ b/crates/goose/tests/acp_custom_requests_test.rs @@ -114,6 +114,38 @@ fn active_run_id_from_update(update: &SessionUpdate) -> Option { .map(ToString::to_string) } +fn queued_steer_message_ids(updates: &[SessionUpdate]) -> Vec { + updates + .iter() + .filter_map(|update| { + let SessionUpdate::SessionInfoUpdate(info) = update else { + return None; + }; + info.meta + .as_ref()? + .get("goose")? + .get("queuedSteer")? + .get("messageId")? + .as_str() + .map(ToString::to_string) + }) + .collect() +} + +fn steer_chunk_message_ids(updates: &[SessionUpdate]) -> Vec { + updates + .iter() + .filter_map(|update| { + let SessionUpdate::UserMessageChunk(chunk) = update else { + return None; + }; + let goose = chunk.meta.as_ref()?.get("goose")?; + goose.get("steer")?.as_bool().filter(|b| *b)?; + goose.get("messageId")?.as_str().map(ToString::to_string) + }) + .collect() +} + fn steer_chunk_texts(updates: &[SessionUpdate]) -> Vec { updates .iter() @@ -385,6 +417,7 @@ fn test_steer_session_adds_input_to_active_prompt() { .block_task(), ); let mut steer_sent = false; + let mut steer_message_id: Option = None; let mut final_response = None; let deadline = tokio::time::Instant::now() + Duration::from_secs(3); @@ -411,6 +444,12 @@ fn test_steer_session_adds_input_to_active_prompt() { .await .unwrap(); assert_eq!(response["runId"], run_id); + let mid = response["messageId"].as_str(); + assert!( + mid.is_some_and(|id| !id.is_empty()), + "steer response must return a messageId for correlation, got: {response:?}" + ); + steer_message_id = mid.map(ToString::to_string); steer_sent = true; } } @@ -437,6 +476,21 @@ fn test_steer_session_adds_input_to_active_prompt() { .any(|t| t.contains("steer while active")), "expected a chunk marked _meta.goose.steer with the steer text, got: {steer_chunks:?}" ); + + // The queued steer must be announced (so a UI can show it as pending) + // and carry the same messageId returned by the steer response and later + // stamped on the picked-up UserMessageChunk. + let steer_message_id = steer_message_id.expect("steer response had no messageId"); + let queued_ids = queued_steer_message_ids(&updates); + assert!( + queued_ids.contains(&steer_message_id), + "expected a queuedSteer SessionInfoUpdate with messageId {steer_message_id:?}, got: {queued_ids:?}" + ); + let picked_up_ids = steer_chunk_message_ids(&updates); + assert!( + picked_up_ids.contains(&steer_message_id), + "picked-up steer chunk must carry the queued messageId {steer_message_id:?} for correlation, got: {picked_up_ids:?}" + ); }); } diff --git a/ui/sdk/src/generated/types.gen.ts b/ui/sdk/src/generated/types.gen.ts index 5512fa0b9b7a..cf4a77c070d9 100644 --- a/ui/sdk/src/generated/types.gen.ts +++ b/ui/sdk/src/generated/types.gen.ts @@ -313,6 +313,12 @@ export type EmbeddedResource = { export type SteerSessionResponse_unstable = { runId: string; + /** + * Stable id of the queued steer message. The same id later appears as + * `messageId` on the streamed `UserMessageChunk` (with `_meta.goose.steer`), + * letting clients correlate a queued steer with its pickup. + */ + messageId: string; }; /** diff --git a/ui/sdk/src/generated/zod.gen.ts b/ui/sdk/src/generated/zod.gen.ts index d40c38d6a218..1b3315435a93 100644 --- a/ui/sdk/src/generated/zod.gen.ts +++ b/ui/sdk/src/generated/zod.gen.ts @@ -314,7 +314,8 @@ export const zSteerSessionRequest_unstable = z.object({ }); export const zSteerSessionResponse_unstable = z.object({ - runId: z.string() + runId: z.string(), + messageId: z.string() }); /**