mirror of
https://github.com/block/goose.git
synced 2026-07-09 16:09:22 +00:00
[goose2] MCP Apps: hydrate and replay app payloads in Goose2 (#8632)
Signed-off-by: Andrew Harvard <aharvard@squareup.com>
This commit is contained in:
parent
e95811966a
commit
b59ef3a46f
24 changed files with 1287 additions and 225 deletions
|
|
@ -81,8 +81,8 @@ pub fn playback(log_file_path: &String) -> io::Result<()> {
|
|||
writeln!(
|
||||
&errors_file,
|
||||
"expected:\n{}\ngot:\n{}",
|
||||
serde_json::to_string(&input_value)?,
|
||||
serde_json::to_string(&entry_value)?
|
||||
serde_json::to_string(&entry_value)?,
|
||||
serde_json::to_string(&input_value)?
|
||||
)?;
|
||||
process::exit(1);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ use crate::acp::fs::AcpTools;
|
|||
use crate::acp::tools::AcpAwareToolMeta;
|
||||
use crate::acp::{PermissionDecision, ACP_CURRENT_MODEL};
|
||||
use crate::agents::extension::{Envs, PLATFORM_EXTENSIONS};
|
||||
use crate::agents::extension_manager::TRUSTED_TOOL_UPDATE_META_KEY;
|
||||
use crate::agents::mcp_client::{GooseMcpHostInfo, McpClientTrait};
|
||||
use crate::agents::platform_extensions::developer::DeveloperClient;
|
||||
use crate::agents::{Agent, AgentConfig, ExtensionConfig, GoosePlatform, SessionConfig};
|
||||
|
|
@ -1532,12 +1533,11 @@ impl GooseAcpAgent {
|
|||
}
|
||||
}
|
||||
|
||||
let update = ToolCallUpdate::new(ToolCallId::new(tool_response.id.clone()), fields)
|
||||
.meta(extract_tool_call_update_meta(tool_response));
|
||||
cx.send_notification(SessionNotification::new(
|
||||
session_id.clone(),
|
||||
SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
|
||||
ToolCallId::new(tool_response.id.clone()),
|
||||
fields,
|
||||
)),
|
||||
SessionUpdate::ToolCallUpdate(update),
|
||||
))?;
|
||||
|
||||
Ok(())
|
||||
|
|
@ -1629,6 +1629,21 @@ fn outcome_to_confirmation(outcome: &RequestPermissionOutcome) -> PermissionConf
|
|||
}
|
||||
}
|
||||
|
||||
fn extract_tool_call_update_meta(
|
||||
tool_response: &crate::conversation::message::ToolResponse,
|
||||
) -> Option<Meta> {
|
||||
let tool_result = tool_response.tool_result.as_ref().ok()?;
|
||||
let goose_meta = tool_result
|
||||
.meta
|
||||
.as_ref()?
|
||||
.0
|
||||
.get(TRUSTED_TOOL_UPDATE_META_KEY)?
|
||||
.clone();
|
||||
let mut meta_map = serde_json::Map::new();
|
||||
meta_map.insert("goose".to_string(), goose_meta);
|
||||
Some(meta_map)
|
||||
}
|
||||
|
||||
fn build_tool_call_content(tool_result: &ToolResult<CallToolResult>) -> Vec<ToolCallContent> {
|
||||
match tool_result {
|
||||
Ok(result) => result
|
||||
|
|
@ -2144,12 +2159,12 @@ impl GooseAcpAgent {
|
|||
}
|
||||
}
|
||||
|
||||
let update =
|
||||
ToolCallUpdate::new(ToolCallId::new(tool_response.id.clone()), fields)
|
||||
.meta(extract_tool_call_update_meta(tool_response));
|
||||
cx.send_notification(SessionNotification::new(
|
||||
args.session_id.clone(),
|
||||
SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
|
||||
ToolCallId::new(tool_response.id.clone()),
|
||||
fields,
|
||||
)),
|
||||
SessionUpdate::ToolCallUpdate(update),
|
||||
))?;
|
||||
}
|
||||
MessageContent::Thinking(thinking) => {
|
||||
|
|
@ -4409,6 +4424,49 @@ print(\"hello, world\")
|
|||
.map(|locs| locs.into_iter().map(|loc| (loc.path, loc.line)).collect())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_tool_call_update_meta_ignores_untrusted_goose_meta() {
|
||||
let response = response_with_meta(Some(serde_json::json!({
|
||||
"goose": {
|
||||
"mcpApp": {
|
||||
"resourceUri": "ui://spoofed/app",
|
||||
},
|
||||
},
|
||||
})));
|
||||
|
||||
assert_eq!(extract_tool_call_update_meta(&response), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_tool_call_update_meta_uses_trusted_meta_only() {
|
||||
let response = response_with_meta(Some(serde_json::json!({
|
||||
"goose": {
|
||||
"mcpApp": {
|
||||
"resourceUri": "ui://spoofed/app",
|
||||
},
|
||||
},
|
||||
TRUSTED_TOOL_UPDATE_META_KEY: {
|
||||
"mcpApp": {
|
||||
"resourceUri": "ui://trusted/app",
|
||||
"extensionName": "weather",
|
||||
"toolName": "weather__render",
|
||||
},
|
||||
},
|
||||
})));
|
||||
|
||||
let extracted = extract_tool_call_update_meta(&response).expect("expected trusted meta");
|
||||
assert_eq!(
|
||||
extracted.get("goose"),
|
||||
Some(&serde_json::json!({
|
||||
"mcpApp": {
|
||||
"resourceUri": "ui://trusted/app",
|
||||
"extensionName": "weather",
|
||||
"toolName": "weather__render",
|
||||
},
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
fn make_session_with_usage(
|
||||
total_tokens: Option<i32>,
|
||||
input_tokens: Option<i32>,
|
||||
|
|
|
|||
|
|
@ -45,8 +45,8 @@ use crate::oauth::oauth_flow;
|
|||
use crate::prompt_template;
|
||||
use crate::subprocess::configure_subprocess;
|
||||
use rmcp::model::{
|
||||
CallToolRequestParams, Content, ErrorCode, ErrorData, GetPromptResult, Prompt, Resource,
|
||||
ResourceContents, ServerInfo, Tool,
|
||||
CallToolRequestParams, CallToolResult, Content, ErrorCode, ErrorData, GetPromptResult, Meta,
|
||||
Prompt, Resource, ResourceContents, ServerInfo, Tool,
|
||||
};
|
||||
use rmcp::transport::auth::AuthClient;
|
||||
use schemars::_private::NoSerialize;
|
||||
|
|
@ -121,6 +121,22 @@ pub struct ExtensionManagerCapabilities {
|
|||
pub host_info: Option<GooseMcpHostInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GooseMcpAppToolAttachment {
|
||||
pub tool_name: String,
|
||||
pub extension_name: String,
|
||||
pub resource_uri: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_meta: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resource_result: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub read_error: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) const TRUSTED_TOOL_UPDATE_META_KEY: &str = "__goose_tool_update_meta";
|
||||
|
||||
/// Manages goose extensions / MCP clients and their interactions
|
||||
pub struct ExtensionManager {
|
||||
extensions: Mutex<HashMap<String, Extension>>,
|
||||
|
|
@ -214,6 +230,68 @@ pub fn get_tool_owner(tool: &Tool) -> Option<String> {
|
|||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
fn get_tool_meta_value(tool: &Tool) -> Option<Value> {
|
||||
tool.meta.as_ref().map(|meta| Value::Object(meta.0.clone()))
|
||||
}
|
||||
|
||||
fn get_tool_resource_uri(tool: &Tool) -> Option<String> {
|
||||
tool.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.0.get("ui"))
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|ui| ui.get("resourceUri"))
|
||||
.and_then(Value::as_str)
|
||||
.map(ToString::to_string)
|
||||
}
|
||||
|
||||
fn remove_untrusted_mcp_app_meta(result: &mut CallToolResult) {
|
||||
let Some(meta) = result.meta.as_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
meta.0.remove(TRUSTED_TOOL_UPDATE_META_KEY);
|
||||
|
||||
let remove_goose = meta
|
||||
.0
|
||||
.get_mut("goose")
|
||||
.and_then(Value::as_object_mut)
|
||||
.map(|goose_meta| {
|
||||
goose_meta.remove("mcpApp");
|
||||
goose_meta.is_empty()
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
if remove_goose {
|
||||
meta.0.remove("goose");
|
||||
}
|
||||
|
||||
if meta.0.is_empty() {
|
||||
result.meta = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_trusted_tool_update_meta(
|
||||
result: &mut CallToolResult,
|
||||
attachment: &GooseMcpAppToolAttachment,
|
||||
) {
|
||||
let Ok(attachment_value) = serde_json::to_value(attachment) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut meta_map = result
|
||||
.meta
|
||||
.as_ref()
|
||||
.map(|meta| meta.0.clone())
|
||||
.unwrap_or_default();
|
||||
let mut trusted_meta = serde_json::Map::new();
|
||||
trusted_meta.insert("mcpApp".to_string(), attachment_value);
|
||||
meta_map.insert(
|
||||
TRUSTED_TOOL_UPDATE_META_KEY.to_string(),
|
||||
Value::Object(trusted_meta),
|
||||
);
|
||||
result.meta = Some(Meta(meta_map));
|
||||
}
|
||||
|
||||
fn is_unprefixed_extension(config: &ExtensionConfig) -> bool {
|
||||
match config {
|
||||
ExtensionConfig::Platform { name, .. } | ExtensionConfig::Builtin { name, .. } => {
|
||||
|
|
@ -241,9 +319,12 @@ pub fn is_hidden_extension(name: &str) -> bool {
|
|||
|
||||
/// Result of resolving a tool call to its owning extension
|
||||
struct ResolvedTool {
|
||||
tool_name: String,
|
||||
extension_name: String,
|
||||
actual_tool_name: String,
|
||||
client: McpClientBox,
|
||||
tool_meta: Option<Value>,
|
||||
resource_uri: Option<String>,
|
||||
}
|
||||
|
||||
async fn child_process_client(
|
||||
|
|
@ -1063,6 +1144,48 @@ impl ExtensionManager {
|
|||
Ok(tools)
|
||||
}
|
||||
|
||||
fn host_supports_mcp_apps(&self) -> bool {
|
||||
if let Some(host_info) = &self.capabilities.host_info {
|
||||
if host_info.explicit_extensions {
|
||||
return host_info.mcpui_enabled();
|
||||
}
|
||||
}
|
||||
|
||||
self.capabilities.mcpui
|
||||
}
|
||||
|
||||
async fn hydrate_mcp_app_attachment(
|
||||
client: &McpClientBox,
|
||||
session_id: &str,
|
||||
resolved_tool: &ResolvedTool,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> Option<GooseMcpAppToolAttachment> {
|
||||
let resource_uri = resolved_tool.resource_uri.clone()?;
|
||||
|
||||
let mut attachment = GooseMcpAppToolAttachment {
|
||||
tool_name: resolved_tool.tool_name.clone(),
|
||||
extension_name: resolved_tool.extension_name.clone(),
|
||||
resource_uri: resource_uri.clone(),
|
||||
tool_meta: resolved_tool.tool_meta.clone(),
|
||||
resource_result: None,
|
||||
read_error: None,
|
||||
};
|
||||
|
||||
match client
|
||||
.read_resource(session_id, &resource_uri, cancellation_token)
|
||||
.await
|
||||
{
|
||||
Ok(resource_result) => {
|
||||
attachment.resource_result = serde_json::to_value(&resource_result).ok();
|
||||
}
|
||||
Err(error) => {
|
||||
attachment.read_error = Some(error.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Some(attachment)
|
||||
}
|
||||
|
||||
async fn invalidate_tools_cache_and_bump_version(&self) {
|
||||
self.tools_cache_version.fetch_add(1, Ordering::SeqCst);
|
||||
*self.tools_cache.lock().await = None;
|
||||
|
|
@ -1432,17 +1555,6 @@ impl ExtensionManager {
|
|||
session_id: &str,
|
||||
tool_name: &str,
|
||||
) -> Result<ResolvedTool, ErrorData> {
|
||||
if let Some((prefix, actual)) = tool_name.split_once("__") {
|
||||
let owner = name_to_key(prefix);
|
||||
if let Some(client) = self.get_server_client(&owner).await {
|
||||
return Ok(ResolvedTool {
|
||||
extension_name: owner,
|
||||
actual_tool_name: actual.to_string(),
|
||||
client,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let tools = self.get_all_tools_cached(session_id).await.map_err(|e| {
|
||||
ErrorData::new(
|
||||
ErrorCode::INTERNAL_ERROR,
|
||||
|
|
@ -1452,13 +1564,19 @@ impl ExtensionManager {
|
|||
})?;
|
||||
|
||||
if let Some(tool) = tools.iter().find(|t| *t.name == *tool_name) {
|
||||
let owner = get_tool_owner(tool).ok_or_else(|| {
|
||||
ErrorData::new(
|
||||
ErrorCode::RESOURCE_NOT_FOUND,
|
||||
format!("Tool '{}' has no owner", tool_name),
|
||||
None,
|
||||
)
|
||||
})?;
|
||||
let owner = get_tool_owner(tool)
|
||||
.or_else(|| {
|
||||
tool_name
|
||||
.split_once("__")
|
||||
.map(|(prefix, _)| name_to_key(prefix))
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
ErrorData::new(
|
||||
ErrorCode::RESOURCE_NOT_FOUND,
|
||||
format!("Tool '{}' has no owner", tool_name),
|
||||
None,
|
||||
)
|
||||
})?;
|
||||
|
||||
let actual_tool_name = tool_name
|
||||
.strip_prefix(&format!("{owner}__"))
|
||||
|
|
@ -1474,12 +1592,29 @@ impl ExtensionManager {
|
|||
})?;
|
||||
|
||||
return Ok(ResolvedTool {
|
||||
tool_name: tool.name.to_string(),
|
||||
extension_name: owner,
|
||||
actual_tool_name,
|
||||
client,
|
||||
tool_meta: get_tool_meta_value(tool),
|
||||
resource_uri: get_tool_resource_uri(tool),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some((prefix, actual)) = tool_name.split_once("__") {
|
||||
let owner = name_to_key(prefix);
|
||||
if let Some(client) = self.get_server_client(&owner).await {
|
||||
return Ok(ResolvedTool {
|
||||
tool_name: tool_name.to_string(),
|
||||
extension_name: owner,
|
||||
actual_tool_name: actual.to_string(),
|
||||
client,
|
||||
tool_meta: None,
|
||||
resource_uri: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Err(ErrorData::new(
|
||||
ErrorCode::RESOURCE_NOT_FOUND,
|
||||
format!("Tool '{}' not found", tool_name),
|
||||
|
|
@ -1515,8 +1650,13 @@ impl ExtensionManager {
|
|||
|
||||
let arguments = tool_call.arguments.clone();
|
||||
let client = resolved.client.clone();
|
||||
let hydration_client = client.clone();
|
||||
let notifications_receiver = client.subscribe().await;
|
||||
let actual_tool_name = resolved.actual_tool_name;
|
||||
let actual_tool_name = resolved.actual_tool_name.clone();
|
||||
let resolved_tool = resolved;
|
||||
let should_hydrate_mcp_app = self.host_supports_mcp_apps();
|
||||
let read_cancellation_token = cancellation_token.clone();
|
||||
let session_id = ctx.session_id.clone();
|
||||
let owned_ctx = ToolCallContext::new(
|
||||
ctx.session_id.clone(),
|
||||
ctx.working_dir.clone(),
|
||||
|
|
@ -1530,7 +1670,7 @@ impl ExtensionManager {
|
|||
owned_ctx.session_id,
|
||||
owned_ctx.working_dir,
|
||||
);
|
||||
client
|
||||
let mut result = client
|
||||
.call_tool(&owned_ctx, &actual_tool_name, arguments, cancellation_token)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
|
|
@ -1538,7 +1678,24 @@ impl ExtensionManager {
|
|||
_ => {
|
||||
ErrorData::new(ErrorCode::INTERNAL_ERROR, e.to_string(), e.maybe_to_value())
|
||||
}
|
||||
})
|
||||
})?;
|
||||
|
||||
remove_untrusted_mcp_app_meta(&mut result);
|
||||
|
||||
if should_hydrate_mcp_app && result.is_error != Some(true) {
|
||||
if let Some(attachment) = Self::hydrate_mcp_app_attachment(
|
||||
&hydration_client,
|
||||
&session_id,
|
||||
&resolved_tool,
|
||||
read_cancellation_token,
|
||||
)
|
||||
.await
|
||||
{
|
||||
insert_trusted_tool_update_meta(&mut result, &attachment);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
};
|
||||
|
||||
Ok(ToolCallResult {
|
||||
|
|
@ -2318,6 +2475,80 @@ mod tests {
|
|||
assert!(!tool_names.iter().any(|n| n.starts_with("ext_b__")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_untrusted_mcp_app_meta_strips_spoofed_payload() {
|
||||
let mut result = CallToolResult::success(vec![]);
|
||||
result.meta = Some(Meta(
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"goose": {
|
||||
"mcpApp": {
|
||||
"resourceUri": "ui://spoofed/app",
|
||||
},
|
||||
"other": true,
|
||||
},
|
||||
TRUSTED_TOOL_UPDATE_META_KEY: {
|
||||
"mcpApp": {
|
||||
"resourceUri": "ui://spoofed/internal",
|
||||
},
|
||||
},
|
||||
}))
|
||||
.unwrap(),
|
||||
));
|
||||
|
||||
remove_untrusted_mcp_app_meta(&mut result);
|
||||
|
||||
let meta = result.meta.expect("expected remaining meta");
|
||||
assert_eq!(meta.0.get(TRUSTED_TOOL_UPDATE_META_KEY), None);
|
||||
assert_eq!(
|
||||
meta.0.get("goose"),
|
||||
Some(&serde_json::json!({ "other": true }))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_trusted_tool_update_meta_stores_backend_payload() {
|
||||
let mut result = CallToolResult::success(vec![]);
|
||||
let attachment = GooseMcpAppToolAttachment {
|
||||
tool_name: "weather__render".to_string(),
|
||||
extension_name: "weather".to_string(),
|
||||
resource_uri: "ui://weather/app".to_string(),
|
||||
tool_meta: None,
|
||||
resource_result: Some(serde_json::json!({
|
||||
"contents": [
|
||||
{
|
||||
"uri": "ui://weather/app",
|
||||
"mimeType": "text/html;profile=mcp-app",
|
||||
"text": "<div>Hello</div>",
|
||||
},
|
||||
],
|
||||
})),
|
||||
read_error: None,
|
||||
};
|
||||
|
||||
insert_trusted_tool_update_meta(&mut result, &attachment);
|
||||
|
||||
let meta = result.meta.expect("expected trusted meta");
|
||||
assert_eq!(
|
||||
meta.0.get(TRUSTED_TOOL_UPDATE_META_KEY),
|
||||
Some(&serde_json::json!({
|
||||
"mcpApp": {
|
||||
"toolName": "weather__render",
|
||||
"extensionName": "weather",
|
||||
"resourceUri": "ui://weather/app",
|
||||
"resourceResult": {
|
||||
"contents": [
|
||||
{
|
||||
"uri": "ui://weather/app",
|
||||
"mimeType": "text/html;profile=mcp-app",
|
||||
"text": "<div>Hello</div>",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_add_extension_noop_on_identical_config() {
|
||||
// When add_extension is called with a config that is byte-for-byte identical to
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ STDERR: time=2025-12-11T17:58:47.640-05:00 level=INFO msg="server session connec
|
|||
STDOUT: {"jsonrpc":"2.0","id":0,"result":{"capabilities":{"completions":{},"logging":{},"prompts":{"listChanged":true},"resources":{"listChanged":true},"tools":{"listChanged":true}},"instructions":"The GitHub MCP Server provides tools to interact with GitHub platform.\n\nTool selection guidance:\n\t1. Use 'list_*' tools for broad, simple retrieval and pagination of all items of a type (e.g., all issues, all PRs, all branches) with basic filtering.\n\t2. Use 'search_*' tools for targeted queries with specific criteria, keywords, or complex filters (e.g., issues with certain text, PRs by author, code containing functions).\n\nContext management:\n\t1. Use pagination whenever possible with batches of 5-10 items.\n\t2. Use minimal_output parameter set to true if the full information is not needed to accomplish a task.\n\nTool usage guidance:\n\t1. For 'search_*' tools: Use separate 'sort' and 'order' parameters if available for sorting results - do not include 'sort:' syntax in query strings. Query strings should contain only search criteria (e.g., 'org:google language:python'), not sorting instructions. Always call 'get_me' first to understand current user permissions and context. ## Issues\n\nCheck 'list_issue_types' first for organizations to use proper issue types. Use 'search_issues' before creating new issues to avoid duplicates. Always set 'state_reason' when closing issues. ## Pull Requests\n\nPR review workflow: Always use 'pull_request_review_write' with method 'create' to create a pending review, then 'add_comment_to_pending_review' to add comments, and finally 'pull_request_review_write' with method 'submit_pending' to submit the review for complex reviews with line-specific comments.\n\nBefore creating a pull request, search for pull request templates in the repository. Template files are called pull_request_template.md or they're located in '.github/PULL_REQUEST_TEMPLATE' directory. Use the template content to structure the PR description and then call create_pull_request tool.","protocolVersion":"2025-03-26","serverInfo":{"name":"github-mcp-server","title":"GitHub MCP Server","version":"0.24.1"}}}
|
||||
STDIN: {"jsonrpc":"2.0","method":"notifications/initialized"}
|
||||
STDERR: time=2025-12-11T17:58:47.642-05:00 level=INFO msg="session initialized"
|
||||
STDIN: {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"_meta":{"agent-session-id":"test-session-id","progressToken":0},"name":"get_file_contents","arguments":{"owner":"block","path":"README.md","repo":"goose","sha":"ab62b863c1666232a67048b6c4e10007a2a5b83c"}}}
|
||||
STDOUT: {"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"successfully downloaded text file (SHA: de9bdde7f260549bf3a083651842f30ab29cf4e9)"},{"type":"resource","resource":{"uri":"repo://block/goose/sha/ab62b863c1666232a67048b6c4e10007a2a5b83c/contents/README.md","mimeType":"text/plain; charset=utf-8","text":"\u003cdiv align=\"center\"\u003e\n\n# goose\n\n_a local, extensible, open source AI agent that automates engineering tasks_\n\n\u003cp align=\"center\"\u003e\n \u003ca href=\"https://opensource.org/licenses/Apache-2.0\"\u003e\n \u003cimg src=\"https://img.shields.io/badge/License-Apache_2.0-blue.svg\"\u003e\n \u003c/a\u003e\n \u003ca href=\"https://discord.gg/7GaTvbDwga\"\u003e\n \u003cimg src=\"https://img.shields.io/discord/1287729918100246654?logo=discord\u0026logoColor=white\u0026label=Join+Us\u0026color=blueviolet\" alt=\"Discord\"\u003e\n \u003c/a\u003e\n \u003ca href=\"https://github.com/block/goose/actions/workflows/ci.yml\"\u003e\n \u003cimg src=\"https://img.shields.io/github/actions/workflow/status/block/goose/ci.yml?branch=main\" alt=\"CI\"\u003e\n \u003c/a\u003e\n\u003c/p\u003e\n\u003c/div\u003e\n\ngoose is your on-machine AI agent, capable of automating complex development tasks from start to finish. More than just code suggestions, goose can build entire projects from scratch, write and execute code, debug failures, orchestrate workflows, and interact with external APIs - _autonomously_.\n\nWhether you're prototyping an idea, refining existing code, or managing intricate engineering pipelines, goose adapts to your workflow and executes tasks with precision.\n\nDesigned for maximum flexibility, goose works with any LLM and supports multi-model configuration to optimize performance and cost, seamlessly integrates with MCP servers, and is available as both a desktop app as well as CLI - making it the ultimate AI assistant for developers who want to move faster and focus on innovation.\n\n[](https://youtu.be/D-DpDunrbpo)\n\n# Quick Links\n- [Quickstart](https://goose-docs.ai/docs/quickstart)\n- [Installation](https://goose-docs.ai/docs/getting-started/installation)\n- [Tutorials](https://goose-docs.ai/docs/category/tutorials)\n- [Documentation](https://goose-docs.ai/docs/category/getting-started)\n\n\n# a little goose humor 🦢\n\n\u003e Why did the developer choose goose as their AI agent?\n\u003e \n\u003e Because it always helps them \"migrate\" their code to production! 🚀\n\n# goose around with us\n- [Discord](https://discord.gg/block-opensource)\n- [YouTube](https://www.youtube.com/@goose-oss)\n- [LinkedIn](https://www.linkedin.com/company/goose-oss)\n- [Twitter/X](https://x.com/goose_oss)\n- [Bluesky](https://bsky.app/profile/opensource.block.xyz)\n- [Nostr](https://njump.me/opensource@block.xyz)\n"}}]}}
|
||||
STDIN: {"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"agent-session-id":"test-session-id","progressToken":0}}}
|
||||
STDOUT: {"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"get_file_contents","description":"Get file contents from GitHub","inputSchema":{"type":"object","properties":{"owner":{"type":"string"},"repo":{"type":"string"},"path":{"type":"string"},"sha":{"type":"string"}},"required":["owner","repo","path"]}}]}}
|
||||
STDIN: {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"_meta":{"agent-session-id":"test-session-id","progressToken":1},"name":"get_file_contents","arguments":{"owner":"block","path":"README.md","repo":"goose","sha":"ab62b863c1666232a67048b6c4e10007a2a5b83c"}}}
|
||||
STDOUT: {"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"successfully downloaded text file (SHA: de9bdde7f260549bf3a083651842f30ab29cf4e9)"},{"type":"resource","resource":{"uri":"repo://block/goose/sha/ab62b863c1666232a67048b6c4e10007a2a5b83c/contents/README.md","mimeType":"text/plain; charset=utf-8","text":"\u003cdiv align=\"center\"\u003e\n\n# goose\n\n_a local, extensible, open source AI agent that automates engineering tasks_\n\n\u003cp align=\"center\"\u003e\n \u003ca href=\"https://opensource.org/licenses/Apache-2.0\"\u003e\n \u003cimg src=\"https://img.shields.io/badge/License-Apache_2.0-blue.svg\"\u003e\n \u003c/a\u003e\n \u003ca href=\"https://discord.gg/7GaTvbDwga\"\u003e\n \u003cimg src=\"https://img.shields.io/discord/1287729918100246654?logo=discord\u0026logoColor=white\u0026label=Join+Us\u0026color=blueviolet\" alt=\"Discord\"\u003e\n \u003c/a\u003e\n \u003ca href=\"https://github.com/block/goose/actions/workflows/ci.yml\"\u003e\n \u003cimg src=\"https://img.shields.io/github/actions/workflow/status/block/goose/ci.yml?branch=main\" alt=\"CI\"\u003e\n \u003c/a\u003e\n\u003c/p\u003e\n\u003c/div\u003e\n\ngoose is your on-machine AI agent, capable of automating complex development tasks from start to finish. More than just code suggestions, goose can build entire projects from scratch, write and execute code, debug failures, orchestrate workflows, and interact with external APIs - _autonomously_.\n\nWhether you're prototyping an idea, refining existing code, or managing intricate engineering pipelines, goose adapts to your workflow and executes tasks with precision.\n\nDesigned for maximum flexibility, goose works with any LLM and supports multi-model configuration to optimize performance and cost, seamlessly integrates with MCP servers, and is available as both a desktop app as well as CLI - making it the ultimate AI assistant for developers who want to move faster and focus on innovation.\n\n[](https://youtu.be/D-DpDunrbpo)\n\n# Quick Links\n- [Quickstart](https://goose-docs.ai/docs/quickstart)\n- [Installation](https://goose-docs.ai/docs/getting-started/installation)\n- [Tutorials](https://goose-docs.ai/docs/category/tutorials)\n- [Documentation](https://goose-docs.ai/docs/category/getting-started)\n\n\n# a little goose humor 🦢\n\n\u003e Why did the developer choose goose as their AI agent?\n\u003e \n\u003e Because it always helps them \"migrate\" their code to production! 🚀\n\n# goose around with us\n- [Discord](https://discord.gg/block-opensource)\n- [YouTube](https://www.youtube.com/@goose-oss)\n- [LinkedIn](https://www.linkedin.com/company/goose-oss)\n- [Twitter/X](https://x.com/goose_oss)\n- [Bluesky](https://bsky.app/profile/opensource.block.xyz)\n- [Nostr](https://njump.me/opensource@block.xyz)\n"}}]}}
|
||||
STDERR: time=2025-12-11T17:58:48.133-05:00 level=INFO msg="server session disconnected" session_id=""
|
||||
|
|
|
|||
|
|
@ -4,20 +4,22 @@ STDOUT: {"result":{"protocolVersion":"2025-03-26","capabilities":{"tools":{"list
|
|||
STDIN: {"jsonrpc":"2.0","method":"notifications/initialized"}
|
||||
STDOUT: {"method":"notifications/tools/list_changed","jsonrpc":"2.0"}
|
||||
STDOUT: {"method":"notifications/tools/list_changed","jsonrpc":"2.0"}
|
||||
STDIN: {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"_meta":{"agent-session-id":"test-session-id","progressToken":0},"name":"echo","arguments":{"message":"Hello, world!"}}}
|
||||
STDOUT: {"result":{"content":[{"type":"text","text":"Echo: Hello, world!"}]},"jsonrpc":"2.0","id":1}
|
||||
STDIN: {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"_meta":{"agent-session-id":"test-session-id","progressToken":1},"name":"get-sum","arguments":{"a":1,"b":2}}}
|
||||
STDOUT: {"result":{"content":[{"type":"text","text":"The sum of 1 and 2 is 3."}]},"jsonrpc":"2.0","id":2}
|
||||
STDIN: {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"_meta":{"agent-session-id":"test-session-id","progressToken":2},"name":"trigger-long-running-operation","arguments":{"duration":1,"steps":5}}}
|
||||
STDOUT: {"method":"notifications/progress","params":{"progress":1,"total":5,"progressToken":2},"jsonrpc":"2.0"}
|
||||
STDOUT: {"method":"notifications/progress","params":{"progress":2,"total":5,"progressToken":2},"jsonrpc":"2.0"}
|
||||
STDOUT: {"method":"notifications/progress","params":{"progress":3,"total":5,"progressToken":2},"jsonrpc":"2.0"}
|
||||
STDOUT: {"method":"notifications/progress","params":{"progress":4,"total":5,"progressToken":2},"jsonrpc":"2.0"}
|
||||
STDOUT: {"method":"notifications/progress","params":{"progress":5,"total":5,"progressToken":2},"jsonrpc":"2.0"}
|
||||
STDOUT: {"result":{"content":[{"type":"text","text":"Long running operation completed. Duration: 1 seconds, Steps: 5."}]},"jsonrpc":"2.0","id":3}
|
||||
STDIN: {"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"_meta":{"agent-session-id":"test-session-id","progressToken":3},"name":"get-structured-content","arguments":{"location":"New York"}}}
|
||||
STDOUT: {"result":{"content":[{"type":"text","text":"{\"temperature\":33,\"conditions\":\"Cloudy\",\"humidity\":82}"}],"structuredContent":{"temperature":33,"conditions":"Cloudy","humidity":82}},"jsonrpc":"2.0","id":4}
|
||||
STDIN: {"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"_meta":{"agent-session-id":"test-session-id","progressToken":4},"name":"trigger-sampling-request","arguments":{"maxTokens":100,"prompt":"Please provide a quote from The Great Gatsby"}}}
|
||||
STDIN: {"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"agent-session-id":"test-session-id","progressToken":0}}}
|
||||
STDOUT: {"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"echo","description":"Echo a message","inputSchema":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"]}},{"name":"get-sum","description":"Get the sum of two numbers","inputSchema":{"type":"object","properties":{"a":{"type":"number"},"b":{"type":"number"}},"required":["a","b"]}},{"name":"trigger-long-running-operation","description":"Trigger a long-running operation","inputSchema":{"type":"object","properties":{"duration":{"type":"number"},"steps":{"type":"number"}},"required":["duration","steps"]}},{"name":"get-structured-content","description":"Get structured content","inputSchema":{"type":"object","properties":{"location":{"type":"string"}},"required":["location"]}},{"name":"trigger-sampling-request","description":"Trigger a sampling request","inputSchema":{"type":"object","properties":{"prompt":{"type":"string"},"maxTokens":{"type":"number"}},"required":["prompt","maxTokens"]}}]}}
|
||||
STDIN: {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"_meta":{"agent-session-id":"test-session-id","progressToken":1},"name":"echo","arguments":{"message":"Hello, world!"}}}
|
||||
STDOUT: {"result":{"content":[{"type":"text","text":"Echo: Hello, world!"}]},"jsonrpc":"2.0","id":2}
|
||||
STDIN: {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"_meta":{"agent-session-id":"test-session-id","progressToken":2},"name":"get-sum","arguments":{"a":1,"b":2}}}
|
||||
STDOUT: {"result":{"content":[{"type":"text","text":"The sum of 1 and 2 is 3."}]},"jsonrpc":"2.0","id":3}
|
||||
STDIN: {"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"_meta":{"agent-session-id":"test-session-id","progressToken":3},"name":"trigger-long-running-operation","arguments":{"duration":1,"steps":5}}}
|
||||
STDOUT: {"method":"notifications/progress","params":{"progress":1,"total":5,"progressToken":3},"jsonrpc":"2.0"}
|
||||
STDOUT: {"method":"notifications/progress","params":{"progress":2,"total":5,"progressToken":3},"jsonrpc":"2.0"}
|
||||
STDOUT: {"method":"notifications/progress","params":{"progress":3,"total":5,"progressToken":3},"jsonrpc":"2.0"}
|
||||
STDOUT: {"method":"notifications/progress","params":{"progress":4,"total":5,"progressToken":3},"jsonrpc":"2.0"}
|
||||
STDOUT: {"method":"notifications/progress","params":{"progress":5,"total":5,"progressToken":3},"jsonrpc":"2.0"}
|
||||
STDOUT: {"result":{"content":[{"type":"text","text":"Long running operation completed. Duration: 1 seconds, Steps: 5."}]},"jsonrpc":"2.0","id":4}
|
||||
STDIN: {"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"_meta":{"agent-session-id":"test-session-id","progressToken":4},"name":"get-structured-content","arguments":{"location":"New York"}}}
|
||||
STDOUT: {"result":{"content":[{"type":"text","text":"{\"temperature\":33,\"conditions\":\"Cloudy\",\"humidity\":82}"}],"structuredContent":{"temperature":33,"conditions":"Cloudy","humidity":82}},"jsonrpc":"2.0","id":5}
|
||||
STDIN: {"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"_meta":{"agent-session-id":"test-session-id","progressToken":5},"name":"trigger-sampling-request","arguments":{"maxTokens":100,"prompt":"Please provide a quote from The Great Gatsby"}}}
|
||||
STDOUT: {"method":"sampling/createMessage","params":{"messages":[{"role":"user","content":{"type":"text","text":"Resource trigger-sampling-request context: Please provide a quote from The Great Gatsby"}}],"systemPrompt":"You are a helpful test server.","maxTokens":100,"temperature":0.7},"jsonrpc":"2.0","id":0}
|
||||
STDIN: {"jsonrpc":"2.0","id":0,"result":{"model":"mock","stopReason":"endTurn","role":"assistant","content":{"type":"text","text":"\"So we beat on, boats against the current, borne back ceaselessly into the past.\" — F. Scott Fitzgerald, The Great Gatsby (1925)"}}}
|
||||
STDOUT: {"result":{"content":[{"type":"text","text":"LLM sampling result: \n{\n \"model\": \"mock\",\n \"stopReason\": \"endTurn\",\n \"role\": \"assistant\",\n \"content\": {\n \"type\": \"text\",\n \"text\": \"\\\"So we beat on, boats against the current, borne back ceaselessly into the past.\\\" — F. Scott Fitzgerald, The Great Gatsby (1925)\"\n }\n}"}]},"jsonrpc":"2.0","id":5}
|
||||
STDOUT: {"result":{"content":[{"type":"text","text":"LLM sampling result: \n{\n \"model\": \"mock\",\n \"stopReason\": \"endTurn\",\n \"role\": \"assistant\",\n \"content\": {\n \"type\": \"text\",\n \"text\": \"\\\"So we beat on, boats against the current, borne back ceaselessly into the past.\\\" — F. Scott Fitzgerald, The Great Gatsby (1925)\"\n }\n}"}]},"jsonrpc":"2.0","id":6}
|
||||
|
|
|
|||
|
|
@ -25,5 +25,7 @@ STDERR: [01/23/26 15:56:13] INFO Starting MCP server 'mymcp' with server
|
|||
STDERR: transport 'stdio'
|
||||
STDOUT: {"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-03-26","capabilities":{"experimental":{},"prompts":{"listChanged":false},"resources":{"subscribe":false,"listChanged":false},"tools":{"listChanged":true},"tasks":{"list":{},"cancel":{},"requests":{"tools":{"call":{}},"prompts":{"get":{}},"resources":{"read":{}}}}},"serverInfo":{"name":"mymcp","version":"2.14.4"}}}
|
||||
STDIN: {"jsonrpc":"2.0","method":"notifications/initialized"}
|
||||
STDIN: {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"_meta":{"agent-session-id":"test-session-id","progressToken":0},"name":"divide","arguments":{"dividend":10,"divisor":2}}}
|
||||
STDOUT: {"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"5.0"}],"structuredContent":{"result":5.0},"isError":false}}
|
||||
STDIN: {"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"agent-session-id":"test-session-id","progressToken":0}}}
|
||||
STDOUT: {"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"divide","description":"Divide two numbers","inputSchema":{"type":"object","properties":{"dividend":{"type":"number"},"divisor":{"type":"number"}},"required":["dividend","divisor"]}}]}}
|
||||
STDIN: {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"_meta":{"agent-session-id":"test-session-id","progressToken":1},"name":"divide","arguments":{"dividend":10,"divisor":2}}}
|
||||
STDOUT: {"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"5.0"}],"structuredContent":{"result":5.0},"isError":false}}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
STDIN: {"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{"elicitation":{},"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}},"roots": {},"sampling":{}},"clientInfo":{"name":"goose-desktop","version":"0.0.0"}}}
|
||||
STDOUT: {"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-03-26","capabilities":{"experimental":{},"prompts":{"listChanged":false},"tools":{"listChanged":false}},"serverInfo":{"name":"mcp-fetch","version":"1.25.0"}}}
|
||||
STDIN: {"jsonrpc":"2.0","method":"notifications/initialized"}
|
||||
STDIN: {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"_meta":{"agent-session-id":"test-session-id","progressToken":0},"name":"fetch","arguments":{"url":"https://example.com"}}}
|
||||
STDOUT: {"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"Contents of https://example.com/:\nThis domain is for use in documentation examples without needing permission. Avoid use in operations.\n\n[Learn more](https://iana.org/domains/example)"}],"isError":false}}
|
||||
STDIN: {"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"agent-session-id":"test-session-id","progressToken":0}}}
|
||||
STDOUT: {"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"fetch","description":"Fetch a URL","inputSchema":{"type":"object","properties":{"url":{"type":"string"}},"required":["url"]}}]}}
|
||||
STDIN: {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"_meta":{"agent-session-id":"test-session-id","progressToken":1},"name":"fetch","arguments":{"url":"https://example.com"}}}
|
||||
STDOUT: {"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"Contents of https://example.com/:\nThis domain is for use in documentation examples without needing permission. Avoid use in operations.\n\n[Learn more](https://iana.org/domains/example)"}],"isError":false}}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,10 @@ export function getBufferedMessage(
|
|||
return replayBuffers.get(sessionId)?.find((m) => m.id === messageId);
|
||||
}
|
||||
|
||||
export function getReplayBuffer(sessionId: string): Message[] | undefined {
|
||||
return replayBuffers.get(sessionId);
|
||||
}
|
||||
|
||||
export function getAndDeleteReplayBuffer(
|
||||
sessionId: string,
|
||||
): Message[] | undefined {
|
||||
|
|
|
|||
22
ui/goose2/src/features/chat/ui/McpAppView.tsx
Normal file
22
ui/goose2/src/features/chat/ui/McpAppView.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { CodeBlock } from "@/shared/ui/ai-elements/code-block";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { McpAppPayload } from "@/shared/types/messages";
|
||||
|
||||
interface McpAppViewProps {
|
||||
payload: McpAppPayload;
|
||||
}
|
||||
|
||||
export function McpAppView({ payload }: McpAppViewProps) {
|
||||
const { t } = useTranslation("chat");
|
||||
|
||||
// Currently we just render the MCP App payload as JSON.
|
||||
// Up next, we'll replace this with actual HTML rendering and host bridging.
|
||||
return (
|
||||
<div className="my-3" data-testid="mcp-app-view">
|
||||
<div className="mb-2 text-muted-foreground text-xs uppercase tracking-wide">
|
||||
{t("message.mcpAppUnderConstruction")}
|
||||
</div>
|
||||
<CodeBlock code={JSON.stringify(payload, null, 2)} language="json" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -32,6 +32,7 @@ import {
|
|||
} from "@/shared/ui/ai-elements/reasoning";
|
||||
import { ToolChainCards, type ToolChainItem } from "./ToolChainCards";
|
||||
import { ClickableImage } from "./ClickableImage";
|
||||
import { McpAppView } from "./McpAppView";
|
||||
import { useArtifactLinkHandler } from "@/features/chat/hooks/useArtifactLinkHandler";
|
||||
import type {
|
||||
Message,
|
||||
|
|
@ -97,10 +98,9 @@ interface ContentSection {
|
|||
items: MessageContent[] | ToolChainItem[];
|
||||
}
|
||||
|
||||
/** Keep only content blocks whose audience includes "user" (or has no audience). */
|
||||
function filterUserVisibleContent(content: MessageContent[]): MessageContent[] {
|
||||
return content.filter((b) => {
|
||||
const aud = b.annotations?.audience;
|
||||
const aud = "annotations" in b ? b.annotations?.audience : undefined;
|
||||
return !aud || aud.length === 0 || aud.includes("user");
|
||||
});
|
||||
}
|
||||
|
|
@ -232,6 +232,8 @@ function renderContentBlock(
|
|||
case "toolResponse":
|
||||
// Handled by groupContentSections toolChain rendering
|
||||
return null;
|
||||
case "mcpApp":
|
||||
return <McpAppView key={`mcp-app-${index}`} payload={content.payload} />;
|
||||
case "thinking":
|
||||
case "reasoning": {
|
||||
const text = (content as ThinkingContent | ReasoningContentType).text;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MessageBubble } from "../MessageBubble";
|
||||
import { useAgentStore } from "@/features/agents/stores/agentStore";
|
||||
import type { Message } from "@/shared/types/messages";
|
||||
|
||||
vi.mock("@tauri-apps/plugin-opener", () => ({
|
||||
openPath: vi.fn(),
|
||||
}));
|
||||
|
||||
function assistantMessage(
|
||||
content: Message["content"],
|
||||
overrides: Partial<Message> = {},
|
||||
): Message {
|
||||
return {
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
created: Date.now(),
|
||||
content,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("MessageBubble MCP app rendering", () => {
|
||||
beforeEach(() => {
|
||||
useAgentStore.setState({ personas: [] });
|
||||
});
|
||||
|
||||
it("renders MCP App blocks", () => {
|
||||
const msg = assistantMessage([
|
||||
{
|
||||
type: "toolRequest",
|
||||
id: "tool-1",
|
||||
name: "weather: open app",
|
||||
arguments: {},
|
||||
status: "completed",
|
||||
},
|
||||
{
|
||||
type: "toolResponse",
|
||||
id: "tool-1",
|
||||
name: "weather: open app",
|
||||
result: "done",
|
||||
isError: false,
|
||||
},
|
||||
{
|
||||
type: "mcpApp",
|
||||
id: "tool-1",
|
||||
payload: {
|
||||
sessionId: "local-session",
|
||||
gooseSessionId: "goose-session",
|
||||
toolCallId: "tool-1",
|
||||
toolCallTitle: "weather: open app",
|
||||
source: "toolCallUpdateMeta",
|
||||
tool: {
|
||||
name: "weather__open_app",
|
||||
extensionName: "weather",
|
||||
resourceUri: "ui://weather/app",
|
||||
},
|
||||
resource: {
|
||||
result: {
|
||||
contents: [
|
||||
{
|
||||
uri: "ui://weather/app",
|
||||
mimeType: "text/html",
|
||||
text: "<div>Hello</div>",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
render(<MessageBubble message={msg} />);
|
||||
|
||||
const mcpAppView = screen.getByTestId("mcp-app-view");
|
||||
expect(mcpAppView).toBeInTheDocument();
|
||||
expect(mcpAppView).toHaveTextContent("ui://weather/app");
|
||||
expect(mcpAppView).toHaveTextContent("<div>Hello</div>");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,293 @@
|
|||
import { waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
clearReplayBuffer,
|
||||
getReplayBuffer,
|
||||
} from "@/features/chat/hooks/replayBuffer";
|
||||
import { useChatStore } from "@/features/chat/stores/chatStore";
|
||||
import type { McpAppPayload } from "@/shared/types/messages";
|
||||
import {
|
||||
clearMessageTracking,
|
||||
handleSessionNotification,
|
||||
setActiveMessageId,
|
||||
} from "../acpNotificationHandler";
|
||||
import { registerSession } from "../acpSessionTracker";
|
||||
|
||||
function createMcpAppPayload(): McpAppPayload {
|
||||
return {
|
||||
sessionId: "local-session",
|
||||
gooseSessionId: "goose-session",
|
||||
toolCallId: "tool-1",
|
||||
toolCallTitle: "mcp_app_bench__inspect_host_info",
|
||||
source: "toolCallUpdateMeta",
|
||||
tool: {
|
||||
name: "mcp_app_bench__inspect_host_info",
|
||||
extensionName: "mcp_app_bench",
|
||||
resourceUri: "ui://inspect-host-info",
|
||||
},
|
||||
resource: {
|
||||
result: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("acpNotificationHandler", () => {
|
||||
beforeEach(() => {
|
||||
clearMessageTracking();
|
||||
clearReplayBuffer("local-session");
|
||||
clearReplayBuffer("goose-session");
|
||||
useChatStore.setState({
|
||||
messagesBySession: {},
|
||||
sessionStateById: {},
|
||||
queuedMessageBySession: {},
|
||||
draftsBySession: {},
|
||||
activeSessionId: null,
|
||||
isConnected: false,
|
||||
loadingSessionIds: new Set<string>(),
|
||||
scrollTargetMessageBySession: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps tool calls that arrive before the first text chunk on the pending assistant message", async () => {
|
||||
registerSession(
|
||||
"local-session",
|
||||
"goose-session",
|
||||
"goose",
|
||||
"/Users/aharvard/.goose/artifacts",
|
||||
);
|
||||
setActiveMessageId("goose-session", "assistant-1");
|
||||
|
||||
await handleSessionNotification({
|
||||
sessionId: "goose-session",
|
||||
update: {
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "tool-1",
|
||||
title: "mcp_app_bench__inspect_host_info",
|
||||
},
|
||||
} as never);
|
||||
|
||||
await handleSessionNotification({
|
||||
sessionId: "goose-session",
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "tool-1",
|
||||
status: "completed",
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: "Opened the Host Info inspector.",
|
||||
},
|
||||
},
|
||||
],
|
||||
_meta: {
|
||||
goose: {
|
||||
mcpApp: {
|
||||
toolName: "mcp_app_bench__inspect_host_info",
|
||||
extensionName: "mcp_app_bench",
|
||||
resourceUri: "ui://inspect-host-info",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
|
||||
await handleSessionNotification({
|
||||
sessionId: "goose-session",
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: {
|
||||
type: "text",
|
||||
text: "The Host Info inspector is now open.",
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
|
||||
await waitFor(() => {
|
||||
const message =
|
||||
useChatStore.getState().messagesBySession["local-session"]?.[0];
|
||||
expect(message?.content.some((block) => block.type === "mcpApp")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
const [message] =
|
||||
useChatStore.getState().messagesBySession["local-session"];
|
||||
expect(message.id).toBe("assistant-1");
|
||||
expect(message.content.map((block) => block.type)).toEqual([
|
||||
"toolRequest",
|
||||
"toolResponse",
|
||||
"mcpApp",
|
||||
"text",
|
||||
]);
|
||||
expect(message.content[0]).toMatchObject({
|
||||
type: "toolRequest",
|
||||
id: "tool-1",
|
||||
name: "mcp_app_bench__inspect_host_info",
|
||||
status: "completed",
|
||||
});
|
||||
expect(message.content[1]).toMatchObject({
|
||||
type: "toolResponse",
|
||||
id: "tool-1",
|
||||
name: "mcp_app_bench__inspect_host_info",
|
||||
result: "Opened the Host Info inspector.",
|
||||
isError: false,
|
||||
});
|
||||
expect(message.content[2]).toMatchObject({
|
||||
type: "mcpApp",
|
||||
id: "tool-1",
|
||||
payload: createMcpAppPayload(),
|
||||
});
|
||||
expect(message.content[3]).toMatchObject({
|
||||
type: "text",
|
||||
text: "The Host Info inspector is now open.",
|
||||
});
|
||||
expect(
|
||||
useChatStore.getState().getSessionRuntime("local-session")
|
||||
.streamingMessageId,
|
||||
).toBe("assistant-1");
|
||||
});
|
||||
|
||||
it("replay keeps tool and MCP app content on an assistant message when tool events arrive before text", async () => {
|
||||
const replaySessionId = "replay-goose-session";
|
||||
useChatStore.setState({
|
||||
loadingSessionIds: new Set<string>([replaySessionId]),
|
||||
});
|
||||
|
||||
await handleSessionNotification({
|
||||
sessionId: replaySessionId,
|
||||
update: {
|
||||
sessionUpdate: "user_message_chunk",
|
||||
messageId: "user-1",
|
||||
content: {
|
||||
type: "text",
|
||||
text: "run the app bench",
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
|
||||
await handleSessionNotification({
|
||||
sessionId: replaySessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "tool-1",
|
||||
title: "mcp_app_bench__inspect_host_info",
|
||||
},
|
||||
} as never);
|
||||
|
||||
await handleSessionNotification({
|
||||
sessionId: replaySessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "tool-1",
|
||||
status: "completed",
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: "Opened the Host Info inspector.",
|
||||
},
|
||||
},
|
||||
],
|
||||
_meta: {
|
||||
goose: {
|
||||
mcpApp: {
|
||||
toolName: "mcp_app_bench__inspect_host_info",
|
||||
extensionName: "mcp_app_bench",
|
||||
resourceUri: "ui://inspect-host-info",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
|
||||
await handleSessionNotification({
|
||||
sessionId: replaySessionId,
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
messageId: "assistant-1",
|
||||
content: {
|
||||
type: "text",
|
||||
text: "The Host Info inspector is now open.",
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
|
||||
const buffer = getReplayBuffer(replaySessionId);
|
||||
expect(buffer).toHaveLength(2);
|
||||
expect(buffer?.[0]).toMatchObject({
|
||||
id: "user-1",
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "run the app bench" }],
|
||||
});
|
||||
expect(
|
||||
buffer?.[0]?.content.some((block) => block.type === "toolRequest"),
|
||||
).toBe(false);
|
||||
|
||||
expect(buffer?.[1]?.id).toBe("assistant-1");
|
||||
expect(buffer?.[1]?.role).toBe("assistant");
|
||||
expect(buffer?.[1]?.content.map((block) => block.type)).toEqual([
|
||||
"toolRequest",
|
||||
"toolResponse",
|
||||
"mcpApp",
|
||||
"text",
|
||||
]);
|
||||
expect(buffer?.[1]?.content[2]).toMatchObject({
|
||||
type: "mcpApp",
|
||||
id: "tool-1",
|
||||
payload: {
|
||||
...createMcpAppPayload(),
|
||||
sessionId: replaySessionId,
|
||||
gooseSessionId: replaySessionId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("replay preserves gooseSessionId in MCP app payloads before tracker registration", async () => {
|
||||
const replaySessionId = "replay-goose-session-2";
|
||||
useChatStore.setState({
|
||||
loadingSessionIds: new Set<string>([replaySessionId]),
|
||||
});
|
||||
|
||||
await handleSessionNotification({
|
||||
sessionId: replaySessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "tool-1",
|
||||
title: "mcp_app_bench__inspect_host_info",
|
||||
},
|
||||
} as never);
|
||||
|
||||
await handleSessionNotification({
|
||||
sessionId: replaySessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "tool-1",
|
||||
status: "completed",
|
||||
_meta: {
|
||||
goose: {
|
||||
mcpApp: {
|
||||
toolName: "mcp_app_bench__inspect_host_info",
|
||||
extensionName: "mcp_app_bench",
|
||||
resourceUri: "ui://inspect-host-info",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
|
||||
const buffer = getReplayBuffer(replaySessionId);
|
||||
const assistant = buffer?.[0];
|
||||
const mcpAppBlock = assistant?.content.find(
|
||||
(block) => block.type === "mcpApp",
|
||||
);
|
||||
expect(mcpAppBlock).toMatchObject({
|
||||
type: "mcpApp",
|
||||
payload: expect.objectContaining({
|
||||
gooseSessionId: replaySessionId,
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -97,17 +97,19 @@ export async function acpSendMessage(
|
|||
const tPrompt = performance.now();
|
||||
const meta: Record<string, unknown> = {};
|
||||
if (personaId) meta.personaId = personaId;
|
||||
await directAcp.prompt(
|
||||
gooseSessionId,
|
||||
content,
|
||||
Object.keys(meta).length > 0 ? meta : undefined,
|
||||
);
|
||||
const tDone = performance.now();
|
||||
perfLog(
|
||||
`[perf:send] ${sid} prompt() resolved in ${(tDone - tPrompt).toFixed(1)}ms (total acpSendMessage ${(tDone - tStart).toFixed(1)}ms)`,
|
||||
);
|
||||
|
||||
clearActiveMessageId(gooseSessionId);
|
||||
try {
|
||||
await directAcp.prompt(
|
||||
gooseSessionId,
|
||||
content,
|
||||
Object.keys(meta).length > 0 ? meta : undefined,
|
||||
);
|
||||
const tDone = performance.now();
|
||||
perfLog(
|
||||
`[perf:send] ${sid} prompt() resolved in ${(tDone - tPrompt).toFixed(1)}ms (total acpSendMessage ${(tDone - tStart).toFixed(1)}ms)`,
|
||||
);
|
||||
} finally {
|
||||
clearActiveMessageId(gooseSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Prepare or warm an ACP session ahead of the first prompt. */
|
||||
|
|
|
|||
|
|
@ -6,10 +6,14 @@ import {
|
|||
getAndDeleteReplayBuffer,
|
||||
} from "@/features/chat/hooks/replayBuffer";
|
||||
import { registerSession } from "./acpSessionTracker";
|
||||
import { handleSessionNotification } from "./acpNotificationHandler";
|
||||
import {
|
||||
clearMessageTracking,
|
||||
handleSessionNotification,
|
||||
} from "./acpNotificationHandler";
|
||||
|
||||
describe("acpNotificationHandler", () => {
|
||||
beforeEach(() => {
|
||||
clearMessageTracking();
|
||||
clearReplayBuffer("draft-session-1");
|
||||
clearReplayBuffer("draft-session-2");
|
||||
useChatStore.setState({
|
||||
|
|
|
|||
|
|
@ -15,6 +15,17 @@ import type {
|
|||
ToolResponseContent,
|
||||
} from "@/shared/types/messages";
|
||||
import type { AcpNotificationHandler } from "./acpConnection";
|
||||
import {
|
||||
attachMcpAppPayload,
|
||||
extractToolResultText,
|
||||
findReplayMessageWithToolCall,
|
||||
} from "./acpToolCallContent";
|
||||
import {
|
||||
clearReplayAssistantMessage,
|
||||
clearReplayAssistantTracking,
|
||||
ensureReplayAssistantMessage,
|
||||
getTrackedReplayAssistantMessageId,
|
||||
} from "./acpReplayAssistant";
|
||||
import {
|
||||
getLocalSessionId,
|
||||
subscribeToSessionRegistration,
|
||||
|
|
@ -23,47 +34,6 @@ import { perfLog } from "@/shared/lib/perfLog";
|
|||
|
||||
// Pre-set message ID for the next live stream per goose session
|
||||
const presetMessageIds = new Map<string, string>();
|
||||
const pendingUsageUpdates = new Map<string, SessionUpdate[]>();
|
||||
|
||||
function shouldBufferPendingUpdate(update: SessionUpdate): boolean {
|
||||
return update.sessionUpdate === "usage_update";
|
||||
}
|
||||
|
||||
function queuePendingUsageUpdate(
|
||||
gooseSessionId: string,
|
||||
update: SessionUpdate,
|
||||
): void {
|
||||
const pending = pendingUsageUpdates.get(gooseSessionId);
|
||||
if (pending) {
|
||||
pending.push(update);
|
||||
return;
|
||||
}
|
||||
pendingUsageUpdates.set(gooseSessionId, [update]);
|
||||
}
|
||||
|
||||
function flushPendingUsageUpdates(
|
||||
localSessionId: string,
|
||||
gooseSessionId: string,
|
||||
): void {
|
||||
const pending = pendingUsageUpdates.get(gooseSessionId);
|
||||
if (!pending?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingUsageUpdates.delete(gooseSessionId);
|
||||
|
||||
for (const update of pending) {
|
||||
if (useChatStore.getState().loadingSessionIds.has(localSessionId)) {
|
||||
handleReplay(localSessionId, update);
|
||||
} else {
|
||||
handleLive(localSessionId, gooseSessionId, update);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
subscribeToSessionRegistration((localSessionId, gooseSessionId) => {
|
||||
flushPendingUsageUpdates(localSessionId, gooseSessionId);
|
||||
});
|
||||
|
||||
// Per-session perf counters for replay/live streaming.
|
||||
interface ReplayPerf {
|
||||
|
|
@ -78,6 +48,20 @@ interface LivePerf {
|
|||
chunkCount: number;
|
||||
}
|
||||
const livePerf = new Map<string, LivePerf>();
|
||||
const pendingUsageUpdates = new Map<
|
||||
string,
|
||||
{ accumulatedTotal: number; contextLimit: number }
|
||||
>();
|
||||
|
||||
subscribeToSessionRegistration((localSessionId, gooseSessionId) => {
|
||||
const pendingUsage = pendingUsageUpdates.get(gooseSessionId);
|
||||
if (!pendingUsage) {
|
||||
return;
|
||||
}
|
||||
|
||||
useChatStore.getState().updateTokenState(localSessionId, pendingUsage);
|
||||
pendingUsageUpdates.delete(gooseSessionId);
|
||||
});
|
||||
|
||||
export function setActiveMessageId(
|
||||
gooseSessionId: string,
|
||||
|
|
@ -112,32 +96,23 @@ export async function handleSessionNotification(
|
|||
notification: SessionNotification,
|
||||
): Promise<void> {
|
||||
const gooseSessionId = notification.sessionId;
|
||||
const { update } = notification;
|
||||
const localSessionId = getLocalSessionId(gooseSessionId);
|
||||
|
||||
if (!localSessionId) {
|
||||
if (shouldBufferPendingUpdate(update)) {
|
||||
queuePendingUsageUpdate(gooseSessionId, update);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const isReplay = useChatStore
|
||||
.getState()
|
||||
.loadingSessionIds.has(localSessionId);
|
||||
const sessionId = localSessionId ?? gooseSessionId;
|
||||
const { update } = notification;
|
||||
const isReplay = useChatStore.getState().loadingSessionIds.has(sessionId);
|
||||
|
||||
if (isReplay) {
|
||||
const sid = localSessionId.slice(0, 8);
|
||||
let perf = replayPerf.get(localSessionId);
|
||||
const sid = sessionId.slice(0, 8);
|
||||
let perf = replayPerf.get(sessionId);
|
||||
const now = performance.now();
|
||||
if (!perf) {
|
||||
perf = { firstAt: now, lastAt: now, count: 0 };
|
||||
replayPerf.set(localSessionId, perf);
|
||||
replayPerf.set(sessionId, perf);
|
||||
perfLog(`[perf:replay] ${sid} first notification received`);
|
||||
}
|
||||
perf.lastAt = now;
|
||||
perf.count += 1;
|
||||
handleReplay(localSessionId, update);
|
||||
handleReplay(sessionId, gooseSessionId, localSessionId, update);
|
||||
} else {
|
||||
const perf = livePerf.get(gooseSessionId);
|
||||
if (perf && update.sessionUpdate === "agent_message_chunk") {
|
||||
|
|
@ -150,7 +125,7 @@ export async function handleSessionNotification(
|
|||
);
|
||||
}
|
||||
}
|
||||
handleLive(localSessionId, gooseSessionId, update);
|
||||
handleLive(sessionId, gooseSessionId, localSessionId, update);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -166,25 +141,18 @@ export function clearReplayPerf(sessionId: string): void {
|
|||
replayPerf.delete(sessionId);
|
||||
}
|
||||
|
||||
function handleReplay(sessionId: string, update: SessionUpdate): void {
|
||||
function handleReplay(
|
||||
sessionId: string,
|
||||
gooseSessionId: string,
|
||||
localSessionId: string | null,
|
||||
update: SessionUpdate,
|
||||
): void {
|
||||
switch (update.sessionUpdate) {
|
||||
case "agent_message_chunk": {
|
||||
const messageId = update.messageId ?? crypto.randomUUID();
|
||||
const buffer = ensureReplayBuffer(sessionId);
|
||||
if (!getBufferedMessage(sessionId, messageId)) {
|
||||
buffer.push({
|
||||
id: messageId,
|
||||
role: "assistant",
|
||||
created: Date.now(),
|
||||
content: [],
|
||||
metadata: {
|
||||
userVisible: true,
|
||||
agentVisible: true,
|
||||
completionStatus: "inProgress",
|
||||
},
|
||||
});
|
||||
}
|
||||
const msg = getBufferedMessage(sessionId, messageId);
|
||||
const msg = ensureReplayAssistantMessage(
|
||||
sessionId,
|
||||
update.messageId ?? null,
|
||||
);
|
||||
if (msg && update.content.type === "text" && "text" in update.content) {
|
||||
const last = msg.content[msg.content.length - 1];
|
||||
if (last?.type === "text") {
|
||||
|
|
@ -197,6 +165,7 @@ function handleReplay(sessionId: string, update: SessionUpdate): void {
|
|||
}
|
||||
|
||||
case "user_message_chunk": {
|
||||
clearReplayAssistantMessage(sessionId);
|
||||
if (update.content.type !== "text" || !("text" in update.content)) break;
|
||||
const messageId = update.messageId ?? crypto.randomUUID();
|
||||
const buffer = ensureReplayBuffer(sessionId);
|
||||
|
|
@ -228,22 +197,25 @@ function handleReplay(sessionId: string, update: SessionUpdate): void {
|
|||
}
|
||||
|
||||
case "tool_call": {
|
||||
const msg = findMessageInBuffer(sessionId, update.toolCallId);
|
||||
if (msg) {
|
||||
msg.content.push({
|
||||
type: "toolRequest",
|
||||
id: update.toolCallId,
|
||||
name: update.title,
|
||||
arguments: {},
|
||||
status: "executing",
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
const msg = ensureReplayAssistantMessage(sessionId);
|
||||
msg.content.push({
|
||||
type: "toolRequest",
|
||||
id: update.toolCallId,
|
||||
name: update.title,
|
||||
arguments: {},
|
||||
status: "executing",
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "tool_call_update": {
|
||||
const msg = findMessageWithToolCall(sessionId, update.toolCallId);
|
||||
const replayMessageId = getTrackedReplayAssistantMessageId(sessionId);
|
||||
const msg =
|
||||
findReplayMessageWithToolCall(sessionId, update.toolCallId) ??
|
||||
(replayMessageId
|
||||
? getBufferedMessage(sessionId, replayMessageId)
|
||||
: undefined);
|
||||
if (msg) {
|
||||
if (update.title) {
|
||||
const tc = msg.content.find(
|
||||
|
|
@ -274,6 +246,19 @@ function handleReplay(sessionId: string, update: SessionUpdate): void {
|
|||
result: resultText,
|
||||
isError: update.status === "failed",
|
||||
});
|
||||
if (update.status === "completed") {
|
||||
attachMcpAppPayload(
|
||||
sessionId,
|
||||
update.toolCallId,
|
||||
(tc as ToolRequestContent)?.name ?? update.title ?? "",
|
||||
update,
|
||||
true,
|
||||
{
|
||||
gooseSessionId,
|
||||
replayMessageId,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
|
@ -282,7 +267,7 @@ function handleReplay(sessionId: string, update: SessionUpdate): void {
|
|||
case "session_info_update":
|
||||
case "config_option_update":
|
||||
case "usage_update":
|
||||
handleShared(sessionId, update);
|
||||
handleShared(sessionId, gooseSessionId, localSessionId, update);
|
||||
break;
|
||||
|
||||
default:
|
||||
|
|
@ -293,36 +278,19 @@ function handleReplay(sessionId: string, update: SessionUpdate): void {
|
|||
function handleLive(
|
||||
sessionId: string,
|
||||
gooseSessionId: string,
|
||||
localSessionId: string | null,
|
||||
update: SessionUpdate,
|
||||
): void {
|
||||
const store = useChatStore.getState();
|
||||
|
||||
switch (update.sessionUpdate) {
|
||||
case "agent_message_chunk": {
|
||||
const messageId =
|
||||
update.messageId ??
|
||||
presetMessageIds.get(gooseSessionId) ??
|
||||
crypto.randomUUID();
|
||||
const existing = store.messagesBySession[sessionId]?.find(
|
||||
(m) => m.id === messageId,
|
||||
const messageId = ensureLiveAssistantMessage(
|
||||
sessionId,
|
||||
gooseSessionId,
|
||||
update.messageId,
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
store.addMessage(sessionId, {
|
||||
id: messageId,
|
||||
role: "assistant",
|
||||
created: Date.now(),
|
||||
content: [],
|
||||
metadata: {
|
||||
userVisible: true,
|
||||
agentVisible: true,
|
||||
completionStatus: "inProgress",
|
||||
},
|
||||
});
|
||||
store.setPendingAssistantProvider(sessionId, null);
|
||||
store.setStreamingMessageId(sessionId, messageId);
|
||||
}
|
||||
|
||||
if (update.content.type === "text" && "text" in update.content) {
|
||||
store.setStreamingMessageId(sessionId, messageId);
|
||||
store.updateStreamingText(sessionId, update.content.text);
|
||||
|
|
@ -331,8 +299,7 @@ function handleLive(
|
|||
}
|
||||
|
||||
case "tool_call": {
|
||||
const messageId = findStreamingMessageId(sessionId);
|
||||
if (!messageId) break;
|
||||
const messageId = ensureLiveAssistantMessage(sessionId, gooseSessionId);
|
||||
|
||||
const toolRequest: ToolRequestContent = {
|
||||
type: "toolRequest",
|
||||
|
|
@ -348,8 +315,7 @@ function handleLive(
|
|||
}
|
||||
|
||||
case "tool_call_update": {
|
||||
const messageId = findStreamingMessageId(sessionId);
|
||||
if (!messageId) break;
|
||||
const messageId = ensureLiveAssistantMessage(sessionId, gooseSessionId);
|
||||
|
||||
if (update.title) {
|
||||
store.updateMessage(sessionId, messageId, (msg) => ({
|
||||
|
|
@ -389,6 +355,15 @@ function handleLive(
|
|||
};
|
||||
store.setStreamingMessageId(sessionId, messageId);
|
||||
store.appendToStreamingMessage(sessionId, toolResponse);
|
||||
if (update.status === "completed") {
|
||||
attachMcpAppPayload(
|
||||
sessionId,
|
||||
update.toolCallId,
|
||||
toolRequest?.name ?? update.title ?? "",
|
||||
update,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
@ -396,7 +371,7 @@ function handleLive(
|
|||
case "session_info_update":
|
||||
case "config_option_update":
|
||||
case "usage_update":
|
||||
handleShared(sessionId, update);
|
||||
handleShared(sessionId, gooseSessionId, localSessionId, update);
|
||||
break;
|
||||
|
||||
default:
|
||||
|
|
@ -404,7 +379,12 @@ function handleLive(
|
|||
}
|
||||
}
|
||||
|
||||
function handleShared(sessionId: string, update: SessionUpdate): void {
|
||||
function handleShared(
|
||||
sessionId: string,
|
||||
gooseSessionId: string,
|
||||
localSessionId: string | null,
|
||||
update: SessionUpdate,
|
||||
): void {
|
||||
switch (update.sessionUpdate) {
|
||||
case "session_info_update": {
|
||||
const info = update as SessionUpdate & {
|
||||
|
|
@ -463,7 +443,16 @@ function handleShared(sessionId: string, update: SessionUpdate): void {
|
|||
|
||||
case "usage_update": {
|
||||
const usage = update as SessionUpdate & { sessionUpdate: "usage_update" };
|
||||
useChatStore.getState().updateTokenState(sessionId, {
|
||||
|
||||
if (!localSessionId) {
|
||||
pendingUsageUpdates.set(gooseSessionId, {
|
||||
accumulatedTotal: usage.used,
|
||||
contextLimit: usage.size,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
useChatStore.getState().updateTokenState(localSessionId, {
|
||||
accumulatedTotal: usage.used,
|
||||
contextLimit: usage.size,
|
||||
});
|
||||
|
|
@ -475,8 +464,6 @@ function handleShared(sessionId: string, update: SessionUpdate): void {
|
|||
}
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
function findStreamingMessageId(sessionId: string): string | null {
|
||||
return useChatStore.getState().getSessionRuntime(sessionId)
|
||||
.streamingMessageId;
|
||||
|
|
@ -489,52 +476,53 @@ function makeTextBlock(
|
|||
return { type: "text", text, ...(ann ? { annotations: ann } : {}) };
|
||||
}
|
||||
|
||||
function findMessageInBuffer(
|
||||
function ensureLiveAssistantMessage(
|
||||
sessionId: string,
|
||||
_toolCallId: string,
|
||||
): ReturnType<typeof getBufferedMessage> {
|
||||
const buffer = ensureReplayBuffer(sessionId);
|
||||
return buffer[buffer.length - 1];
|
||||
}
|
||||
gooseSessionId: string,
|
||||
preferredMessageId?: string | null,
|
||||
): string {
|
||||
const store = useChatStore.getState();
|
||||
const existingStreamingMessageId = findStreamingMessageId(sessionId);
|
||||
const messages = store.messagesBySession[sessionId] ?? [];
|
||||
|
||||
function findMessageWithToolCall(
|
||||
sessionId: string,
|
||||
toolCallId: string,
|
||||
): ReturnType<typeof getBufferedMessage> {
|
||||
const buffer = ensureReplayBuffer(sessionId);
|
||||
for (let i = buffer.length - 1; i >= 0; i--) {
|
||||
const msg = buffer[i];
|
||||
if (
|
||||
msg.content.some((c) => c.type === "toolRequest" && c.id === toolCallId)
|
||||
) {
|
||||
return msg;
|
||||
}
|
||||
if (
|
||||
existingStreamingMessageId &&
|
||||
messages.some((message) => message.id === existingStreamingMessageId)
|
||||
) {
|
||||
return existingStreamingMessageId;
|
||||
}
|
||||
return buffer[buffer.length - 1];
|
||||
}
|
||||
|
||||
function extractToolResultText(update: {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: ACP SDK ToolCallContent type is complex
|
||||
content?: Array<any> | null;
|
||||
rawOutput?: unknown;
|
||||
}): string {
|
||||
if (update.content && update.content.length > 0) {
|
||||
for (const item of update.content) {
|
||||
if (item.type === "content" && item.content?.type === "text") {
|
||||
return item.content.text;
|
||||
}
|
||||
}
|
||||
const messageId =
|
||||
preferredMessageId ??
|
||||
presetMessageIds.get(gooseSessionId) ??
|
||||
existingStreamingMessageId ??
|
||||
crypto.randomUUID();
|
||||
|
||||
if (!messages.some((message) => message.id === messageId)) {
|
||||
store.addMessage(sessionId, {
|
||||
id: messageId,
|
||||
role: "assistant",
|
||||
created: Date.now(),
|
||||
content: [],
|
||||
metadata: {
|
||||
userVisible: true,
|
||||
agentVisible: true,
|
||||
completionStatus: "inProgress",
|
||||
},
|
||||
});
|
||||
}
|
||||
if (update.rawOutput !== undefined && update.rawOutput !== null) {
|
||||
return typeof update.rawOutput === "string"
|
||||
? update.rawOutput
|
||||
: JSON.stringify(update.rawOutput);
|
||||
}
|
||||
return "";
|
||||
|
||||
store.setPendingAssistantProvider(sessionId, null);
|
||||
store.setStreamingMessageId(sessionId, messageId);
|
||||
clearActiveMessageId(gooseSessionId);
|
||||
|
||||
return messageId;
|
||||
}
|
||||
|
||||
export function clearMessageTracking(): void {
|
||||
presetMessageIds.clear();
|
||||
pendingUsageUpdates.clear();
|
||||
clearReplayAssistantTracking();
|
||||
}
|
||||
|
||||
const handler: AcpNotificationHandler = {
|
||||
|
|
|
|||
64
ui/goose2/src/shared/api/acpReplayAssistant.ts
Normal file
64
ui/goose2/src/shared/api/acpReplayAssistant.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import {
|
||||
ensureReplayBuffer,
|
||||
getBufferedMessage,
|
||||
} from "@/features/chat/hooks/replayBuffer";
|
||||
import type { Message } from "@/shared/types/messages";
|
||||
|
||||
const replayAssistantMessageIds = new Map<string, string>();
|
||||
|
||||
export function getTrackedReplayAssistantMessageId(
|
||||
sessionId: string,
|
||||
): string | null {
|
||||
return replayAssistantMessageIds.get(sessionId) ?? null;
|
||||
}
|
||||
|
||||
export function ensureReplayAssistantMessage(
|
||||
sessionId: string,
|
||||
preferredMessageId?: string | null,
|
||||
): Message {
|
||||
const trackedMessageId = replayAssistantMessageIds.get(sessionId);
|
||||
|
||||
if (preferredMessageId) {
|
||||
const preferredMessage = getBufferedMessage(sessionId, preferredMessageId);
|
||||
if (preferredMessage?.role === "assistant") {
|
||||
replayAssistantMessageIds.set(sessionId, preferredMessageId);
|
||||
return preferredMessage;
|
||||
}
|
||||
}
|
||||
|
||||
if (trackedMessageId) {
|
||||
const trackedMessage = getBufferedMessage(sessionId, trackedMessageId);
|
||||
if (trackedMessage?.role === "assistant") {
|
||||
if (preferredMessageId && trackedMessage.id !== preferredMessageId) {
|
||||
trackedMessage.id = preferredMessageId;
|
||||
replayAssistantMessageIds.set(sessionId, preferredMessageId);
|
||||
}
|
||||
return trackedMessage;
|
||||
}
|
||||
}
|
||||
|
||||
const messageId = preferredMessageId ?? crypto.randomUUID();
|
||||
const buffer = ensureReplayBuffer(sessionId);
|
||||
const message: Message = {
|
||||
id: messageId,
|
||||
role: "assistant",
|
||||
created: Date.now(),
|
||||
content: [],
|
||||
metadata: {
|
||||
userVisible: true,
|
||||
agentVisible: true,
|
||||
completionStatus: "inProgress",
|
||||
},
|
||||
};
|
||||
buffer.push(message);
|
||||
replayAssistantMessageIds.set(sessionId, messageId);
|
||||
return message;
|
||||
}
|
||||
|
||||
export function clearReplayAssistantMessage(sessionId: string): void {
|
||||
replayAssistantMessageIds.delete(sessionId);
|
||||
}
|
||||
|
||||
export function clearReplayAssistantTracking(): void {
|
||||
replayAssistantMessageIds.clear();
|
||||
}
|
||||
150
ui/goose2/src/shared/api/acpToolCallContent.ts
Normal file
150
ui/goose2/src/shared/api/acpToolCallContent.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import type { SessionUpdate } from "@agentclientprotocol/sdk";
|
||||
import { useChatStore } from "@/features/chat/stores/chatStore";
|
||||
import {
|
||||
getReplayBuffer,
|
||||
getBufferedMessage,
|
||||
} from "@/features/chat/hooks/replayBuffer";
|
||||
import type { McpAppContent, MessageContent } from "@/shared/types/messages";
|
||||
import { buildMcpAppPayloadFromToolUpdate } from "./mcpAppToolUpdate";
|
||||
|
||||
export function findReplayMessageWithToolCall(
|
||||
sessionId: string,
|
||||
toolCallId: string,
|
||||
): ReturnType<typeof getBufferedMessage> {
|
||||
const buffer = getReplayBuffer(sessionId);
|
||||
if (!buffer) {
|
||||
return undefined;
|
||||
}
|
||||
for (let index = buffer.length - 1; index >= 0; index -= 1) {
|
||||
const message = buffer[index];
|
||||
if (
|
||||
message.content.some(
|
||||
(content) =>
|
||||
content.type === "toolRequest" && content.id === toolCallId,
|
||||
)
|
||||
) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function extractToolResultText(update: {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: ACP SDK ToolCallContent type is complex
|
||||
content?: Array<any> | null;
|
||||
rawOutput?: unknown;
|
||||
}): string {
|
||||
if (update.content && update.content.length > 0) {
|
||||
for (const item of update.content) {
|
||||
if (item.type === "content" && item.content?.type === "text") {
|
||||
return item.content.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (update.rawOutput !== undefined && update.rawOutput !== null) {
|
||||
return typeof update.rawOutput === "string"
|
||||
? update.rawOutput
|
||||
: JSON.stringify(update.rawOutput);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function attachMcpAppPayload(
|
||||
sessionId: string,
|
||||
toolCallId: string,
|
||||
toolCallTitle: string,
|
||||
update: SessionUpdate,
|
||||
isReplay: boolean,
|
||||
options?: {
|
||||
gooseSessionId?: string | null;
|
||||
replayMessageId?: string | null;
|
||||
},
|
||||
): void {
|
||||
const payload = buildMcpAppPayloadFromToolUpdate(
|
||||
sessionId,
|
||||
toolCallId,
|
||||
toolCallTitle,
|
||||
update,
|
||||
options?.gooseSessionId,
|
||||
);
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
const block: McpAppContent = {
|
||||
type: "mcpApp",
|
||||
id: toolCallId,
|
||||
payload,
|
||||
};
|
||||
|
||||
if (isReplay) {
|
||||
const message =
|
||||
findReplayMessageWithToolCall(sessionId, toolCallId) ??
|
||||
(options?.replayMessageId
|
||||
? getBufferedMessage(sessionId, options.replayMessageId)
|
||||
: undefined);
|
||||
if (message) {
|
||||
message.content = insertMcpAppContent(message.content, block);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const store = useChatStore.getState();
|
||||
const message = [...(store.messagesBySession[sessionId] ?? [])]
|
||||
.reverse()
|
||||
.find((candidate) =>
|
||||
candidate.content.some(
|
||||
(content) =>
|
||||
content.type === "toolRequest" && content.id === toolCallId,
|
||||
),
|
||||
);
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
|
||||
store.updateMessage(sessionId, message.id, (current) => ({
|
||||
...current,
|
||||
content: insertMcpAppContent(current.content, block),
|
||||
}));
|
||||
}
|
||||
|
||||
function insertMcpAppContent(
|
||||
content: MessageContent[],
|
||||
block: McpAppContent,
|
||||
): MessageContent[] {
|
||||
if (content.some((item) => item.type === "mcpApp" && item.id === block.id)) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const insertAfterIndex = findMcpAppAnchorIndex(content, block.id);
|
||||
if (insertAfterIndex === -1) {
|
||||
return [...content, block];
|
||||
}
|
||||
|
||||
return [
|
||||
...content.slice(0, insertAfterIndex + 1),
|
||||
block,
|
||||
...content.slice(insertAfterIndex + 1),
|
||||
];
|
||||
}
|
||||
|
||||
function findMcpAppAnchorIndex(
|
||||
content: MessageContent[],
|
||||
toolCallId: string,
|
||||
): number {
|
||||
for (let index = content.length - 1; index >= 0; index -= 1) {
|
||||
const block = content[index];
|
||||
if (block.type === "toolResponse" && block.id === toolCallId) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
for (let index = content.length - 1; index >= 0; index -= 1) {
|
||||
const block = content[index];
|
||||
if (block.type === "toolRequest" && block.id === toolCallId) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
62
ui/goose2/src/shared/api/mcpAppToolUpdate.ts
Normal file
62
ui/goose2/src/shared/api/mcpAppToolUpdate.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import type {
|
||||
GooseMcpAppToolPayload,
|
||||
GooseReadResourceResult,
|
||||
GooseToolCallUpdateMeta,
|
||||
GooseToolMetadata,
|
||||
} from "@aaif/goose-sdk";
|
||||
import type { SessionUpdate } from "@agentclientprotocol/sdk";
|
||||
import type { McpAppPayload } from "@/shared/types/messages";
|
||||
import { getGooseSessionId } from "./acpSessionTracker";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function extractMcpAppPayload(
|
||||
update: SessionUpdate,
|
||||
): GooseMcpAppToolPayload | null {
|
||||
if (update.sessionUpdate !== "tool_call_update" || !isRecord(update._meta)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const meta = update._meta as GooseToolCallUpdateMeta;
|
||||
const payload = meta.goose?.mcpApp;
|
||||
return isRecord(payload) ? (payload as GooseMcpAppToolPayload) : null;
|
||||
}
|
||||
|
||||
export function buildMcpAppPayloadFromToolUpdate(
|
||||
sessionId: string,
|
||||
toolCallId: string,
|
||||
toolCallTitle: string,
|
||||
update: SessionUpdate,
|
||||
gooseSessionIdOverride?: string | null,
|
||||
): McpAppPayload | null {
|
||||
const payload = extractMcpAppPayload(update);
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
gooseSessionId:
|
||||
gooseSessionIdOverride ?? getGooseSessionId(sessionId) ?? null,
|
||||
toolCallId,
|
||||
toolCallTitle,
|
||||
source: "toolCallUpdateMeta",
|
||||
tool: {
|
||||
name: payload.toolName,
|
||||
extensionName: payload.extensionName,
|
||||
resourceUri: payload.resourceUri,
|
||||
meta: isRecord(payload.toolMeta)
|
||||
? (payload.toolMeta as GooseToolMetadata)
|
||||
: undefined,
|
||||
},
|
||||
resource: {
|
||||
result:
|
||||
(payload.resourceResult as GooseReadResourceResult | null) ?? null,
|
||||
...(typeof payload.readError === "string"
|
||||
? { readError: payload.readError }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -126,6 +126,7 @@
|
|||
"message": {
|
||||
"copied": "Copied",
|
||||
"defaultImageAlt": "Attached",
|
||||
"mcpAppUnderConstruction": "🚧 MCP App Rendering is under construction",
|
||||
"redactedThinking": "(thinking redacted)"
|
||||
},
|
||||
"persona": {
|
||||
|
|
|
|||
|
|
@ -126,6 +126,7 @@
|
|||
"message": {
|
||||
"copied": "Copiado",
|
||||
"defaultImageAlt": "Adjunto",
|
||||
"mcpAppUnderConstruction": "🚧 La renderización de MCP App está en construcción",
|
||||
"redactedThinking": "(pensamiento redactado)"
|
||||
},
|
||||
"persona": {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
import type {
|
||||
GooseReadResourceResult,
|
||||
GooseToolMetadata,
|
||||
} from "@aaif/goose-sdk";
|
||||
|
||||
export type ChatAttachmentKind = "image" | "file" | "directory";
|
||||
|
||||
export interface ChatImageAttachmentDraft {
|
||||
|
|
@ -89,6 +94,30 @@ export interface ToolResponseContent {
|
|||
annotations?: ContentAnnotations;
|
||||
}
|
||||
|
||||
export interface McpAppPayload {
|
||||
sessionId: string;
|
||||
gooseSessionId: string | null;
|
||||
toolCallId: string;
|
||||
toolCallTitle: string;
|
||||
source: "toolCallUpdateMeta";
|
||||
tool: {
|
||||
name: string;
|
||||
extensionName: string;
|
||||
resourceUri: string;
|
||||
meta?: GooseToolMetadata;
|
||||
};
|
||||
resource: {
|
||||
result: GooseReadResourceResult | null;
|
||||
readError?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface McpAppContent {
|
||||
type: "mcpApp";
|
||||
id: string;
|
||||
payload: McpAppPayload;
|
||||
}
|
||||
|
||||
export interface ThinkingContent {
|
||||
type: "thinking";
|
||||
text: string;
|
||||
|
|
@ -129,6 +158,7 @@ export type MessageContent =
|
|||
| ImageContent
|
||||
| ToolRequestContent
|
||||
| ToolResponseContent
|
||||
| McpAppContent
|
||||
| ThinkingContent
|
||||
| RedactedThinkingContent
|
||||
| ReasoningContent
|
||||
|
|
@ -181,6 +211,9 @@ export function isToolRequest(c: MessageContent): c is ToolRequestContent {
|
|||
export function isToolResponse(c: MessageContent): c is ToolResponseContent {
|
||||
return c.type === "toolResponse";
|
||||
}
|
||||
export function isMcpApp(c: MessageContent): c is McpAppContent {
|
||||
return c.type === "mcpApp";
|
||||
}
|
||||
export function isThinking(c: MessageContent): c is ThinkingContent {
|
||||
return c.type === "thinking";
|
||||
}
|
||||
|
|
|
|||
3
ui/pnpm-lock.yaml
generated
3
ui/pnpm-lock.yaml
generated
|
|
@ -651,6 +651,9 @@ importers:
|
|||
'@modelcontextprotocol/ext-apps':
|
||||
specifier: ^0.3.1
|
||||
version: 0.3.1(@modelcontextprotocol/sdk@1.27.1(zod@3.25.76))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@3.25.76)
|
||||
'@modelcontextprotocol/sdk':
|
||||
specifier: ^1.27.0
|
||||
version: 1.27.1(zod@3.25.76)
|
||||
zod:
|
||||
specifier: ^3.25.76
|
||||
version: 3.25.76
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/ext-apps": "^0.3.1",
|
||||
"@modelcontextprotocol/sdk": "^1.27.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,17 @@ import type {
|
|||
Implementation,
|
||||
InitializeRequest,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import { RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps";
|
||||
import { RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||
import type {
|
||||
McpUiAppResourceConfig,
|
||||
McpUiAppToolConfig,
|
||||
} from "@modelcontextprotocol/ext-apps/server";
|
||||
import type {
|
||||
BlobResourceContents,
|
||||
ReadResourceResult,
|
||||
TextResourceContents,
|
||||
Tool,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
export const GOOSE_MCP_UI_EXTENSION_ID = "io.modelcontextprotocol/ui" as const;
|
||||
|
||||
|
|
@ -14,6 +24,50 @@ export interface GooseMcpHostCapabilities {
|
|||
extensions: Record<string, GooseMcpUiExtensionSettings>;
|
||||
}
|
||||
|
||||
export type GooseToolUiMetadata = Extract<
|
||||
McpUiAppToolConfig["_meta"],
|
||||
{ ui: unknown }
|
||||
>["ui"];
|
||||
|
||||
export type GooseToolMetadata = NonNullable<Tool["_meta"]> & {
|
||||
ui?: GooseToolUiMetadata;
|
||||
goose_extension?: string;
|
||||
};
|
||||
|
||||
export type GooseSessionTool = Tool & {
|
||||
meta?: GooseToolMetadata;
|
||||
_meta?: GooseToolMetadata;
|
||||
};
|
||||
|
||||
export type GooseTextResourceContents = TextResourceContents;
|
||||
|
||||
export type GooseBlobResourceContents = BlobResourceContents;
|
||||
|
||||
export type GooseResourceContents = TextResourceContents | BlobResourceContents;
|
||||
|
||||
export type GooseReadResourceResult = ReadResourceResult;
|
||||
|
||||
export type GooseResourceMetadata = NonNullable<
|
||||
Extract<NonNullable<McpUiAppResourceConfig["_meta"]>, { ui?: unknown }>["ui"]
|
||||
>;
|
||||
|
||||
export interface GooseMcpAppToolPayload {
|
||||
toolName: string;
|
||||
extensionName: string;
|
||||
resourceUri: string;
|
||||
toolMeta?: GooseToolMetadata;
|
||||
resourceResult?: GooseReadResourceResult | null;
|
||||
readError?: string;
|
||||
}
|
||||
|
||||
export interface GooseToolCallUpdateMeta {
|
||||
goose?: {
|
||||
mcpApp?: GooseMcpAppToolPayload;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface GooseClientMeta {
|
||||
goose: {
|
||||
mcpHostCapabilities: GooseMcpHostCapabilities;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue