diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34201a59c182..b8e7ab0f7398 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -194,6 +194,13 @@ jobs: source ./bin/activate-hermit just check-acp-schema + - name: Test ACP Client SDK + run: | + source ./bin/activate-hermit + cd ui/sdk + pnpm test + pnpm run typecheck:test + desktop-lint: name: Test and Lint Electron Desktop App runs-on: macos-latest diff --git a/Cargo.lock b/Cargo.lock index 199955151b5d..fd7f5d235be1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -268,6 +268,48 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" +[[package]] +name = "askama" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4744ed2eef2645831b441d8f5459689ade2ab27c854488fbab1fbe94fce1a7" +dependencies = [ + "askama_derive", + "itoa", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "askama_derive" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d661e0f57be36a5c14c48f78d09011e67e0cb618f269cca9f2fd8d15b68c46ac" +dependencies = [ + "askama_parser", + "basic-toml", + "memchr", + "proc-macro2", + "quote", + "rustc-hash 2.1.2", + "serde", + "serde_derive", + "syn 2.0.117", +] + +[[package]] +name = "askama_parser" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf315ce6524c857bb129ff794935cf6d42c82a6cff60526fe2a63593de4d0d4f" +dependencies = [ + "memchr", + "serde", + "serde_derive", + "winnow 0.7.15", +] + [[package]] name = "asn1-rs" version = "0.7.2" @@ -945,7 +987,7 @@ dependencies = [ "arc-swap", "bytes", "either", - "fs-err", + "fs-err 3.3.0", "http 1.4.1", "http-body 1.0.1", "hyper", @@ -1028,6 +1070,15 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + [[package]] name = "bat" version = "0.26.1" @@ -1831,6 +1882,29 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "castaway" version = "0.2.4" @@ -3920,6 +3994,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "fs-err" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41" +dependencies = [ + "autocfg", +] + [[package]] name = "fs-err" version = "3.3.0" @@ -4444,6 +4527,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "goblin" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47" +dependencies = [ + "log", + "plain", + "scroll", +] + [[package]] name = "goose" version = "1.37.0" @@ -4475,12 +4569,12 @@ dependencies = [ "encoding_rs", "env-lock", "etcetera 0.11.0", - "fs-err", + "fs-err 3.3.0", "fs2", "futures", "goose-acp-macros", "goose-mcp", - "goose-sdk", + "goose-sdk-types", "goose-test-support", "http 1.4.1", "http-body-util", @@ -4671,14 +4765,25 @@ dependencies = [ [[package]] name = "goose-sdk" version = "1.37.0" +dependencies = [ + "agent-client-protocol", + "agent-client-protocol-schema", + "goose-sdk-types", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "uniffi", +] + +[[package]] +name = "goose-sdk-types" +version = "1.37.0" dependencies = [ "agent-client-protocol", "agent-client-protocol-schema", "schemars 1.2.1", "serde", "serde_json", - "tokio", - "tokio-util", ] [[package]] @@ -7692,7 +7797,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", - "itertools 0.13.0", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.117", @@ -8690,6 +8795,26 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scroll" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6" +dependencies = [ + "scroll_derive", +] + +[[package]] +name = "scroll_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "scrypt" version = "0.11.0" @@ -8777,6 +8902,10 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "seq-macro" @@ -10943,6 +11072,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + [[package]] name = "toml" version = "0.9.12+spec-1.1.0" @@ -11562,6 +11700,127 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" +[[package]] +name = "uniffi" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3291800a6b06569f7d3e15bdb6dc235e0f0c8bd3eb07177f430057feb076415f" +dependencies = [ + "anyhow", + "camino", + "cargo_metadata", + "clap", + "uniffi_bindgen", + "uniffi_core", + "uniffi_macros", + "uniffi_pipeline", +] + +[[package]] +name = "uniffi_bindgen" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a04b99fa7796eaaa7b87976a0dbdd1178dc1ee702ea00aca2642003aef9b669e" +dependencies = [ + "anyhow", + "askama", + "camino", + "cargo_metadata", + "fs-err 2.11.0", + "glob", + "goblin", + "heck", + "indexmap 2.14.0", + "once_cell", + "serde", + "tempfile", + "textwrap", + "toml 0.5.11", + "uniffi_internal_macros", + "uniffi_meta", + "uniffi_pipeline", + "uniffi_udl", +] + +[[package]] +name = "uniffi_core" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38a9a27529ccff732f8efddb831b65b1e07f7dea3fd4cacd4a35a8c4b253b98" +dependencies = [ + "anyhow", + "bytes", + "once_cell", + "static_assertions", +] + +[[package]] +name = "uniffi_internal_macros" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09acd2ce09c777dd65ee97c251d33c8a972afc04873f1e3b21eb3492ade16933" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "uniffi_macros" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5596f178c4f7aafa1a501c4e0b96236a96bc2ef92bdb453d83e609dad0040152" +dependencies = [ + "camino", + "fs-err 2.11.0", + "once_cell", + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", + "toml 0.5.11", + "uniffi_meta", +] + +[[package]] +name = "uniffi_meta" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "beadc1f460eb2e209263c49c4f5b19e9a02e00a3b2b393f78ad10d766346ecff" +dependencies = [ + "anyhow", + "siphasher 0.3.11", + "uniffi_internal_macros", + "uniffi_pipeline", +] + +[[package]] +name = "uniffi_pipeline" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd76b3ac8a2d964ca9fce7df21c755afb4c77b054a85ad7a029ad179cc5abb8a" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.14.0", + "tempfile", + "uniffi_internal_macros", +] + +[[package]] +name = "uniffi_udl" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4319cf905911d70d5b97ce0f46f101619a22e9a189c8c46d797a9955e9233716" +dependencies = [ + "anyhow", + "textwrap", + "uniffi_meta", + "weedle2", +] + [[package]] name = "unit-prefix" version = "0.5.2" @@ -11990,6 +12249,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weedle2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "weezl" version = "0.1.12" @@ -12480,6 +12748,9 @@ name = "winnow" version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] [[package]] name = "winnow" diff --git a/crates/goose-sdk-types/Cargo.toml b/crates/goose-sdk-types/Cargo.toml new file mode 100644 index 000000000000..0414365d04e2 --- /dev/null +++ b/crates/goose-sdk-types/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "goose-sdk-types" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description = "Shared types for the Goose SDK" + +[dependencies] +agent-client-protocol = { workspace = true, features = ["unstable"] } +agent-client-protocol-schema = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +schemars = { workspace = true, features = ["derive"] } + +[package.metadata.cargo-machete] +# Used to provide extras imports for agent-client-protocol +ignored = ["agent-client-protocol-schema"] diff --git a/crates/goose-sdk-types/src/custom_notifications.rs b/crates/goose-sdk-types/src/custom_notifications.rs new file mode 100644 index 000000000000..44424fb3b3b1 --- /dev/null +++ b/crates/goose-sdk-types/src/custom_notifications.rs @@ -0,0 +1,173 @@ +use crate::custom_requests::CustomMethodSchema; +use agent_client_protocol::{JsonRpcMessage, JsonRpcNotification}; +use schemars::{JsonSchema, SchemaGenerator}; +use serde::{Deserialize, Serialize}; + +/// Goose-custom session update notification — a parallel to ACP's +/// `session/update` carrying goose-specific update variants. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcNotification)] +#[notification(method = "_goose/unstable/session/update")] +#[serde(rename_all = "camelCase")] +pub struct GooseSessionNotification { + pub session_id: String, + pub update: GooseSessionUpdate, +} + +/// Discriminated union of goose-specific session update payloads. +/// Variant tag matches ACP's convention (`sessionUpdate: ""`). +/// +/// `discriminator.mapping` is what makes TS codegen (`@hey-api/openapi-ts`) +/// emit the correct snake_case tag value even when this enum has a single +/// variant. Add a mapping entry per variant. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "sessionUpdate", rename_all = "snake_case")] +#[schemars(extend("discriminator" = { + "propertyName": "sessionUpdate", + "mapping": { + "usage_update": "#/$defs/SessionUsageUpdate", + "status_message": "#/$defs/StatusMessageUpdate", + "interaction_update": "#/$defs/InteractionUpdate" + } +}))] +pub enum GooseSessionUpdate { + UsageUpdate(SessionUsageUpdate), + StatusMessage(StatusMessageUpdate), + InteractionUpdate(InteractionUpdate), +} + +impl Default for GooseSessionUpdate { + fn default() -> Self { + GooseSessionUpdate::UsageUpdate(SessionUsageUpdate::default()) + } +} + +/// Streaming context-window usage update for a session. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SessionUsageUpdate { + pub used: u64, + pub context_limit: u64, + pub accumulated_input_tokens: u64, + pub accumulated_output_tokens: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub accumulated_cost: Option, +} + +/// Live UI/session status. This is not conversation transcript content, and +/// should not be persisted or replayed as history. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct StatusMessageUpdate { + pub status: StatusMessage, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum StatusMessage { + #[serde(rename_all = "camelCase")] + Notice { message: String }, + #[serde(rename_all = "camelCase")] + Progress { message: String }, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct InteractionUpdate { + pub interaction: Interaction, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "_meta")] + pub meta: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Interaction { + #[serde(rename_all = "camelCase")] + Elicitation { + id: String, + state: InteractionState, + #[serde(skip_serializing_if = "Option::is_none")] + message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + requested_schema: Option, + }, +} + +impl Default for Interaction { + fn default() -> Self { + Self::Elicitation { + id: String::new(), + state: InteractionState::Pending, + message: None, + requested_schema: None, + } + } +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum InteractionState { + #[default] + Pending, + Submitted, +} + +fn notification_schema(generator: &mut SchemaGenerator) -> CustomMethodSchema +where + T: Default + JsonRpcMessage + JsonSchema, +{ + let dummy = T::default(); + let type_name = std::any::type_name::() + .rsplit("::") + .next() + .unwrap_or(std::any::type_name::()) + .to_string(); + CustomMethodSchema { + method: dummy.method().to_string(), + params_schema: Some(generator.subschema_for::()), + params_type_name: Some(type_name), + response_schema: None, + response_type_name: None, + } +} + +/// Schemas for every goose-custom outbound notification. To register a new +/// notification, define the struct above (with `JsonRpcNotification` + +/// `Default`) and add one line below. +pub fn custom_notification_schemas(generator: &mut SchemaGenerator) -> Vec { + vec![notification_schema::(generator)] +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn status_message_serializes_to_expected_wire_shape() { + let notification = GooseSessionNotification { + session_id: "s1".to_string(), + update: GooseSessionUpdate::StatusMessage(StatusMessageUpdate { + status: StatusMessage::Notice { + message: "Compaction complete".to_string(), + }, + }), + }; + + let value = serde_json::to_value(notification).unwrap(); + + assert_eq!( + value, + json!({ + "sessionId": "s1", + "update": { + "sessionUpdate": "status_message", + "status": { + "type": "notice", + "message": "Compaction complete" + } + } + }) + ); + } +} diff --git a/crates/goose-sdk/src/custom_requests.rs b/crates/goose-sdk-types/src/custom_requests.rs similarity index 100% rename from crates/goose-sdk/src/custom_requests.rs rename to crates/goose-sdk-types/src/custom_requests.rs diff --git a/crates/goose-sdk-types/src/lib.rs b/crates/goose-sdk-types/src/lib.rs new file mode 100644 index 000000000000..5b5021306945 --- /dev/null +++ b/crates/goose-sdk-types/src/lib.rs @@ -0,0 +1,8 @@ +//! Shared types for the Goose SDK. +//! +//! These wire types are used by both the ACP client/server path and the +//! in-process uniffi bindings, keeping a single source of truth for Goose's +//! custom `_goose/*` JSON-RPC methods. + +pub mod custom_notifications; +pub mod custom_requests; diff --git a/crates/goose-sdk/.gitignore b/crates/goose-sdk/.gitignore new file mode 100644 index 000000000000..4d0904c04aa8 --- /dev/null +++ b/crates/goose-sdk/.gitignore @@ -0,0 +1,2 @@ +generated +examples/uniffi/*.jar diff --git a/crates/goose-sdk/Cargo.toml b/crates/goose-sdk/Cargo.toml index 5cdfc6bb072c..17a6ec4b545c 100644 --- a/crates/goose-sdk/Cargo.toml +++ b/crates/goose-sdk/Cargo.toml @@ -6,14 +6,28 @@ rust-version.workspace = true authors.workspace = true license.workspace = true repository.workspace = true -description = "Rust SDK for talking to Goose over the Agent Client Protocol (ACP)" +description = "Rust SDK for Goose with optional uniffi bindings for Python/Kotlin" + +[lib] +name = "goose_sdk" +crate-type = ["cdylib", "staticlib", "rlib"] + +[[bin]] +name = "goose-uniffi-bindgen" +path = "src/bin/uniffi-bindgen.rs" +required-features = ["uniffi"] + +[features] +default = [] +uniffi = ["dep:uniffi", "dep:thiserror"] [dependencies] +goose-sdk-types = { path = "../goose-sdk-types" } agent-client-protocol = { workspace = true, features = ["unstable"] } agent-client-protocol-schema = { workspace = true } -serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true } -schemars = { workspace = true, features = ["derive"] } + +uniffi = { version = "0.29", features = ["cli"], optional = true } +thiserror = { version = "2", optional = true } [dev-dependencies] tokio = { workspace = true } diff --git a/crates/goose-sdk/README.md b/crates/goose-sdk/README.md new file mode 100644 index 000000000000..3dfb839e374c --- /dev/null +++ b/crates/goose-sdk/README.md @@ -0,0 +1,16 @@ +# goose-sdk + +The bindings layer for Goose. It houses the shared types used for both ACP and +SDK access, and exposes a cross-language version of the Goose API. + +With `--features uniffi` the crate compiles to native bindings for Python and +Kotlin (namespace `aaif_goose` / `aaif.goose`). The published surface is +currently a `ping` -> `pong` stub in `src/bindings.rs` — the scaffold for the +real implementation. + +```bash +just python # build bindings + run examples/uniffi/ping.py +just kotlin # build bindings + run examples/uniffi/Ping.kt +``` + +Both print `pong: aaif.io`. diff --git a/crates/goose-sdk/examples/uniffi/Ping.kt b/crates/goose-sdk/examples/uniffi/Ping.kt new file mode 100644 index 000000000000..0f043ee00301 --- /dev/null +++ b/crates/goose-sdk/examples/uniffi/Ping.kt @@ -0,0 +1,9 @@ +package aaif.example + +import aaif.goose.Client + +fun main() { + val client = Client() + val pong = client.ping("aaif.io") + println(pong.message) +} diff --git a/crates/goose-sdk/examples/uniffi/ping.py b/crates/goose-sdk/examples/uniffi/ping.py new file mode 100644 index 000000000000..9503b048080d --- /dev/null +++ b/crates/goose-sdk/examples/uniffi/ping.py @@ -0,0 +1,21 @@ +"""Minimal Goose SDK demo: ping the SDK and print the pong.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE.parent.parent / "generated")) + +from aaif_goose import Client # noqa: E402 + + +def main() -> None: + client = Client() + pong = client.ping("aaif.io") + print(pong.message) + + +if __name__ == "__main__": + main() diff --git a/crates/goose-sdk/justfile b/crates/goose-sdk/justfile new file mode 100644 index 000000000000..4d7acf0151e2 --- /dev/null +++ b/crates/goose-sdk/justfile @@ -0,0 +1,38 @@ +set shell := ["bash", "-cu"] +set working-directory := '../..' + +lib_ext := if os() == "macos" { "dylib" } else if os() == "windows" { "dll" } else { "so" } +lib_dir := "./target/debug" +lib_path := lib_dir / "libgoose_sdk." + lib_ext +bindgen := "./target/debug/goose-uniffi-bindgen" +config := "./crates/goose-sdk/uniffi.toml" +gen_dir := "./crates/goose-sdk/generated" +examples_dir := "./crates/goose-sdk/examples/uniffi" + +default: + @just --list --justfile {{justfile()}} + +_build: + cargo build -p goose-sdk --features uniffi -q + +_generate lang: _build + {{bindgen}} generate --library {{lib_path}} --config {{config}} --language {{lang}} --no-format --out-dir {{gen_dir}} 2>/dev/null + cp {{lib_path}} {{gen_dir}}/ + touch {{gen_dir}}/__init__.py + +python: (_generate "python") + DYLD_LIBRARY_PATH={{lib_dir}} LD_LIBRARY_PATH={{lib_dir}} \ + python3 {{examples_dir}}/ping.py + +kotlin: (_generate "kotlin") + @if [ ! -f {{examples_dir}}/jna.jar ]; then \ + curl -sSL -o {{examples_dir}}/jna.jar \ + https://repo1.maven.org/maven2/net/java/dev/jna/jna/5.14.0/jna-5.14.0.jar; \ + fi + kotlinc -cp {{examples_dir}}/jna.jar -nowarn \ + {{gen_dir}}/aaif/goose/aaif_goose.kt \ + {{examples_dir}}/Ping.kt \ + -include-runtime -d {{examples_dir}}/ping.jar 2>/dev/null + java -Djna.library.path={{lib_dir}} \ + --enable-native-access=ALL-UNNAMED \ + -cp {{examples_dir}}/ping.jar:{{examples_dir}}/jna.jar aaif.example.PingKt diff --git a/crates/goose-sdk/src/bin/uniffi-bindgen.rs b/crates/goose-sdk/src/bin/uniffi-bindgen.rs new file mode 100644 index 000000000000..f6cff6cf1d99 --- /dev/null +++ b/crates/goose-sdk/src/bin/uniffi-bindgen.rs @@ -0,0 +1,3 @@ +fn main() { + uniffi::uniffi_bindgen_main() +} diff --git a/crates/goose-sdk/src/bindings.rs b/crates/goose-sdk/src/bindings.rs new file mode 100644 index 000000000000..a601a744c024 --- /dev/null +++ b/crates/goose-sdk/src/bindings.rs @@ -0,0 +1,69 @@ +//! In-process uniffi bindings for the Goose SDK. +//! +//! This is the published API surface exposed to Python and Kotlin. Right now it +//! is a minimal `ping` -> `pong` round-trip that proves the uniffi +//! infrastructure end to end without depending on the `goose` core crate. +//! +//! To build the real SDK, add `goose` (and whatever else you need) as +//! dependencies and replace the [`Client`] methods below with the actual +//! agent surface. + +use std::sync::Arc; + +/// Errors surfaced across the uniffi boundary. +#[derive(Debug, thiserror::Error, uniffi::Error)] +pub enum GooseError { + #[error("{0}")] + Generic(String), +} + +/// A reply to a [`Client::ping`] call. +#[derive(Debug, Clone, uniffi::Record)] +pub struct Pong { + /// Echo of the message that was pinged. + pub message: String, +} + +/// The top-level entry point for the Goose SDK. +/// +/// This is the object that consuming languages instantiate. Today it only knows +/// how to answer a ping; extend it with the real agent API. +#[derive(uniffi::Object)] +pub struct Client {} + +#[uniffi::export] +impl Client { + #[uniffi::constructor] + pub fn new() -> Arc { + Arc::new(Self {}) + } + + /// Round-trip a message through the SDK. Returns a [`Pong`] echoing the + /// supplied `message`, prefixed with `pong: `. + pub fn ping(&self, message: String) -> Result { + if message.is_empty() { + return Err(GooseError::Generic("message must not be empty".into())); + } + Ok(Pong { + message: format!("pong: {message}"), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ping_returns_pong() { + let client = Client::new(); + let pong = client.ping("aaif.io".into()).expect("ping should succeed"); + assert_eq!(pong.message, "pong: aaif.io"); + } + + #[test] + fn empty_ping_errors() { + let client = Client::new(); + assert!(client.ping(String::new()).is_err()); + } +} diff --git a/crates/goose-sdk/src/lib.rs b/crates/goose-sdk/src/lib.rs index eb50a0179e87..bab0fa2a5296 100644 --- a/crates/goose-sdk/src/lib.rs +++ b/crates/goose-sdk/src/lib.rs @@ -1,2 +1,21 @@ -pub mod custom_notifications; -pub mod custom_requests; +//! Goose SDK. +//! +//! With default features this crate re-exports the shared SDK wire types from +//! `goose-sdk-types` so you can build an Agent Client Protocol (ACP) client +//! that talks to `goose acp` over stdio. +//! +//! With `--features uniffi` the crate additionally compiles as a +//! `cdylib`/`staticlib` and exposes a small in-process API to Python and Kotlin +//! via [uniffi-rs](https://github.com/mozilla/uniffi-rs). +//! +//! The published uniffi surface is intentionally a single `ping` -> `pong` +//! round-trip. It exists as a working scaffold for adding the real Goose SDK +//! API: replace [`bindings`] with the actual implementation. + +pub use goose_sdk_types::{custom_notifications, custom_requests}; + +#[cfg(feature = "uniffi")] +uniffi::setup_scaffolding!("aaif_goose"); + +#[cfg(feature = "uniffi")] +pub mod bindings; diff --git a/crates/goose-sdk/uniffi.toml b/crates/goose-sdk/uniffi.toml new file mode 100644 index 000000000000..7c78ef712132 --- /dev/null +++ b/crates/goose-sdk/uniffi.toml @@ -0,0 +1,2 @@ +[bindings.kotlin] +package_name = "aaif.goose" diff --git a/crates/goose/Cargo.toml b/crates/goose/Cargo.toml index 909dcb60836b..90d12ae00ab8 100644 --- a/crates/goose/Cargo.toml +++ b/crates/goose/Cargo.toml @@ -122,7 +122,7 @@ strum = { workspace = true } once_cell = { workspace = true } etcetera = { workspace = true } fs-err = { version = "3.1", default-features = false } -goose-sdk = { path = "../goose-sdk", default-features = false } +goose-sdk-types = { path = "../goose-sdk-types" } rand = { workspace = true } utoipa = { workspace = true, features = ["chrono"] } tokio-cron-scheduler = { version = "0.15", default-features = false } diff --git a/crates/goose/src/acp/mod.rs b/crates/goose/src/acp/mod.rs index 1a0396a47714..5b1bf99f6965 100644 --- a/crates/goose/src/acp/mod.rs +++ b/crates/goose/src/acp/mod.rs @@ -10,8 +10,7 @@ pub(crate) mod tools; pub mod transport; pub use common::{map_permission_response, PermissionDecision}; -pub use goose_sdk::custom_notifications; -pub use goose_sdk::custom_requests; +pub use goose_sdk_types::{custom_notifications, custom_requests}; pub use provider::{ extension_configs_to_mcp_servers, AcpProvider, AcpProviderConfig, ACP_CURRENT_MODEL, }; diff --git a/crates/goose/src/acp/response_builder.rs b/crates/goose/src/acp/response_builder.rs index 7a93bce5580e..d108aa117dbc 100644 --- a/crates/goose/src/acp/response_builder.rs +++ b/crates/goose/src/acp/response_builder.rs @@ -215,10 +215,13 @@ fn available_commands_update(working_dir: &std::path::Path) -> AvailableCommands pub(super) fn send_session_setup_notifications( cx: &ConnectionTo, session: &Session, + supports_goose_custom_notifications: bool, ) -> Result<(), agent_client_protocol::Error> { let session_id = SessionId::new(session.id.clone()); if let Some(updates) = build_usage_updates(session) { - cx.send_notification(updates.custom)?; + if supports_goose_custom_notifications { + cx.send_notification(updates.custom)?; + } cx.send_notification(SessionNotification::new( session_id.clone(), SessionUpdate::UsageUpdate(updates.standard), diff --git a/crates/goose/src/acp/server.rs b/crates/goose/src/acp/server.rs index d6bd5f7f1d97..7cefd1e000c2 100644 --- a/crates/goose/src/acp/server.rs +++ b/crates/goose/src/acp/server.rs @@ -208,6 +208,7 @@ pub struct GooseAcpAgent { client_fs_capabilities: OnceCell, client_terminal: OnceCell, client_mcp_host_info: OnceCell, + client_supports_goose_custom_notifications: OnceCell, use_login_shell_path: OnceCell, client_cx: OnceCell>, config_dir: std::path::PathBuf, @@ -421,15 +422,17 @@ fn extract_timeout_from_meta(meta: &Option) -> Option { } #[derive(Debug, Default, Deserialize)] -struct GooseClientMetaEnvelope { +struct ClientCapabilitiesMeta { #[serde(default)] - goose: Option, + goose: Option, } #[derive(Debug, Default, Deserialize)] -struct GooseClientMeta { +struct GooseClientCapabilities { #[serde(rename = "mcpHostCapabilities", default)] mcp_host_capabilities: Option, + #[serde(rename = "customNotifications", default)] + custom_notifications: Option, } #[derive(Debug, Default, Deserialize)] @@ -438,24 +441,25 @@ struct GooseMcpHostCapabilities { extensions: Option, } -fn extract_goose_client_meta(meta: &Meta) -> Option { - serde_json::from_value(serde_json::Value::Object(meta.clone())).ok() -} - -fn extract_client_mcp_host_info(args: &InitializeRequest) -> GooseMcpHostInfo { - let host_capabilities = args - .client_capabilities +fn extract_client_capabilities_meta(args: &InitializeRequest) -> Option { + args.client_capabilities .meta .as_ref() - .and_then(extract_goose_client_meta) - .and_then(|meta| meta.goose) - .and_then(|goose| goose.mcp_host_capabilities); + .and_then(|meta| serde_json::from_value(serde_json::Value::Object(meta.clone())).ok()) +} + +fn extract_client_mcp_host_info( + args: &InitializeRequest, + goose_client_capabilities: Option<&GooseClientCapabilities>, +) -> GooseMcpHostInfo { + let host_capabilities = + goose_client_capabilities.and_then(|goose| goose.mcp_host_capabilities.as_ref()); let explicit_extensions = host_capabilities .as_ref() .and_then(|capabilities| capabilities.extensions.as_ref()) .is_some(); let extensions = host_capabilities - .and_then(|capabilities| capabilities.extensions) + .and_then(|capabilities| capabilities.extensions.clone()) .unwrap_or_default(); GooseMcpHostInfo { @@ -1002,6 +1006,13 @@ impl GooseAcpAgent { Arc::clone(&self.permission_manager) } + pub(super) fn supports_goose_custom_notifications(&self) -> bool { + self.client_supports_goose_custom_notifications + .get() + .copied() + .unwrap_or(false) + } + // TODO: goose reads Paths::in_state_dir globally (e.g. RequestLog), ignoring this data_dir. pub async fn new(options: GooseAcpAgentOptions) -> Result { let session_manager = Arc::new(SessionManager::new(options.data_dir)); @@ -1032,6 +1043,7 @@ impl GooseAcpAgent { client_fs_capabilities: OnceCell::new(), client_terminal: OnceCell::new(), client_mcp_host_info: OnceCell::new(), + client_supports_goose_custom_notifications: OnceCell::new(), use_login_shell_path: OnceCell::new(), client_cx: OnceCell::new(), config_dir: options.config_dir, @@ -1468,18 +1480,28 @@ impl GooseAcpAgent { } => { send_elicitation_interaction_update( cx, + self.supports_goose_custom_notifications(), session_id.0.as_ref(), - id.clone(), - InteractionState::Pending, - Some(message.clone()), - Some(requested_schema.clone()), - Some(interaction_update_meta(message_id, message_created)), + InteractionUpdate { + interaction: Interaction::Elicitation { + id: id.clone(), + state: InteractionState::Pending, + message: Some(message.clone()), + requested_schema: Some(requested_schema.clone()), + }, + meta: Some(interaction_update_meta(message_id, message_created)), + }, )?; } ActionRequiredData::ElicitationResponse { .. } => {} }, MessageContent::SystemNotification(notification) => { - send_status_message_update(cx, session_id.0.as_ref(), notification)?; + send_status_message_update( + cx, + self.supports_goose_custom_notifications(), + session_id.0.as_ref(), + notification, + )?; } _ => {} } @@ -1996,6 +2018,14 @@ impl GooseAcpAgent { } } +fn extract_client_supports_goose_custom_notifications( + goose_client_capabilities: Option<&GooseClientCapabilities>, +) -> bool { + goose_client_capabilities + .and_then(|goose| goose.custom_notifications) + .unwrap_or(false) +} + fn outcome_to_confirmation(outcome: &RequestPermissionOutcome) -> PermissionConfirmation { PermissionConfirmation { principal_type: PrincipalType::Tool, @@ -2043,14 +2073,17 @@ fn credits_exhausted_prompt_error( fn send_status_message_update( cx: &ConnectionTo, + supports_goose_custom_notifications: bool, session_id: &str, notification: &SystemNotificationContent, ) -> Result<(), agent_client_protocol::Error> { if let Some(status) = status_message_from_system_notification(notification) { - cx.send_notification(GooseSessionNotification { - session_id: session_id.to_string(), - update: GooseSessionUpdate::StatusMessage(StatusMessageUpdate { status }), - })?; + if supports_goose_custom_notifications { + cx.send_notification(GooseSessionNotification { + session_id: session_id.to_string(), + update: GooseSessionUpdate::StatusMessage(StatusMessageUpdate { status }), + })?; + } } Ok(()) } @@ -2071,25 +2104,17 @@ fn status_message_from_system_notification( fn send_elicitation_interaction_update( cx: &ConnectionTo, + supports_goose_custom_notifications: bool, session_id: &str, - id: String, - state: InteractionState, - message: Option, - requested_schema: Option, - meta: Option, + update: InteractionUpdate, ) -> Result<(), agent_client_protocol::Error> { - cx.send_notification(GooseSessionNotification { - session_id: session_id.to_string(), - update: GooseSessionUpdate::InteractionUpdate(InteractionUpdate { - interaction: Interaction::Elicitation { - id, - state, - message, - requested_schema, - }, - meta, - }), - }) + if supports_goose_custom_notifications { + cx.send_notification(GooseSessionNotification { + session_id: session_id.to_string(), + update: GooseSessionUpdate::InteractionUpdate(update), + })?; + } + Ok(()) } fn interaction_update_meta(message_id: Option<&str>, created: i64) -> serde_json::Value { @@ -2221,9 +2246,15 @@ impl GooseAcpAgent { .client_fs_capabilities .set(args.client_capabilities.fs.clone()); let _ = self.client_terminal.set(args.client_capabilities.terminal); - let _ = self - .client_mcp_host_info - .set(extract_client_mcp_host_info(&args)); + let goose_client_capabilities = + extract_client_capabilities_meta(&args).and_then(|meta| meta.goose); + let _ = self.client_mcp_host_info.set(extract_client_mcp_host_info( + &args, + goose_client_capabilities.as_ref(), + )); + let _ = self.client_supports_goose_custom_notifications.set( + extract_client_supports_goose_custom_notifications(goose_client_capabilities.as_ref()), + ); let _ = self .use_login_shell_path .set(extract_use_login_shell_path(&args)); @@ -2513,7 +2544,9 @@ impl GooseAcpAgent { .await .internal_err_ctx("Failed to load session")?; if let Some(updates) = build_usage_updates(&session) { - cx.send_notification(updates.custom)?; + if self.supports_goose_custom_notifications() { + cx.send_notification(updates.custom)?; + } // Standard ACP notification — emitted alongside the custom one for // backwards compatibility. Remove once all known clients have // migrated to `_goose/unstable/session/update`. @@ -2590,15 +2623,20 @@ impl GooseAcpAgent { send_elicitation_interaction_update( cx, + self.supports_goose_custom_notifications(), &req.session_id, - req.elicitation_id, - InteractionState::Submitted, - None, - None, - Some(interaction_update_meta( - response_message.id.as_deref(), - response_message.created, - )), + InteractionUpdate { + interaction: Interaction::Elicitation { + id: req.elicitation_id, + state: InteractionState::Submitted, + message: None, + requested_schema: None, + }, + meta: Some(interaction_update_meta( + response_message.id.as_deref(), + response_message.created, + )), + }, )?; Ok(EmptyResponse {}) @@ -3722,4 +3760,39 @@ print(\"hello, world\") let session = make_session_with_usage(Some(120), Some(80), Some(40), None, None, None); assert!(build_usage_updates(&session).is_none()); } + + #[test] + fn test_goose_custom_notifications_capability_defaults_to_false() { + let request = + InitializeRequest::new(agent_client_protocol::schema::ProtocolVersion::LATEST); + let goose_client_capabilities = + extract_client_capabilities_meta(&request).and_then(|meta| meta.goose); + + assert!(!extract_client_supports_goose_custom_notifications( + goose_client_capabilities.as_ref() + )); + } + + #[test] + fn test_goose_custom_notifications_capability_reads_client_meta() { + let mut goose_meta = serde_json::Map::new(); + goose_meta.insert( + "customNotifications".to_string(), + serde_json::Value::Bool(true), + ); + let mut meta = serde_json::Map::new(); + meta.insert("goose".to_string(), serde_json::Value::Object(goose_meta)); + + let request = + InitializeRequest::new(agent_client_protocol::schema::ProtocolVersion::LATEST) + .client_capabilities( + agent_client_protocol::schema::ClientCapabilities::new().meta(meta), + ); + let goose_client_capabilities = + extract_client_capabilities_meta(&request).and_then(|meta| meta.goose); + + assert!(extract_client_supports_goose_custom_notifications( + goose_client_capabilities.as_ref() + )); + } } diff --git a/crates/goose/src/acp/server/fork_session.rs b/crates/goose/src/acp/server/fork_session.rs index c0e3c764b47a..976dd047dff8 100644 --- a/crates/goose/src/acp/server/fork_session.rs +++ b/crates/goose/src/acp/server/fork_session.rs @@ -66,7 +66,11 @@ impl GooseAcpAgent { if let Some(co) = config_options { response = response.config_options(co); } - send_session_setup_notifications(cx, &goose_session)?; + send_session_setup_notifications( + cx, + &goose_session, + self.supports_goose_custom_notifications(), + )?; Ok(response) } } diff --git a/crates/goose/src/acp/server/load_session.rs b/crates/goose/src/acp/server/load_session.rs index 6a2fc8626335..94681a3bcf47 100644 --- a/crates/goose/src/acp/server/load_session.rs +++ b/crates/goose/src/acp/server/load_session.rs @@ -29,6 +29,7 @@ fn send_replay_content_chunk( fn replay_conversation_to_client( cx: &ConnectionTo, session: &Session, + supports_goose_custom_notifications: bool, ) -> Result, agent_client_protocol::Error> { let session_id = SessionId::new(session.id.clone()); @@ -165,12 +166,19 @@ fn replay_conversation_to_client( if !submitted_elicitation_ids.contains(id) { send_elicitation_interaction_update( cx, + supports_goose_custom_notifications, session_id.0.as_ref(), - id.clone(), - InteractionState::Pending, - Some(elicitation_message.clone()), - Some(requested_schema.clone()), - Some(serde_json::Value::Object(replay_message_meta(message))), + InteractionUpdate { + interaction: Interaction::Elicitation { + id: id.clone(), + state: InteractionState::Pending, + message: Some(elicitation_message.clone()), + requested_schema: Some(requested_schema.clone()), + }, + meta: Some(serde_json::Value::Object(replay_message_meta( + message, + ))), + }, )?; } } @@ -226,7 +234,11 @@ impl GooseAcpAgent { .prepare_session_for_activation(session, args.cwd.clone(), args.mcp_servers, true) .await?; - let replay_tool_requests = replay_conversation_to_client(cx, &session)?; + let replay_tool_requests = replay_conversation_to_client( + cx, + &session, + self.supports_goose_custom_notifications(), + )?; let (agent, extension_results) = self.prepare_acp_session_agent(cx, &session).await?; self.register_acp_session(session_id_str.clone(), agent.clone(), replay_tool_requests) .await; @@ -245,7 +257,7 @@ impl GooseAcpAgent { let (mode_state, model_state, config_options) = build_session_setup_config(&self.provider_inventory, &session).await?; - send_session_setup_notifications(cx, &session)?; + send_session_setup_notifications(cx, &session, self.supports_goose_custom_notifications())?; let mut response = LoadSessionResponse::new().modes(mode_state); if let Some(ms) = model_state { diff --git a/crates/goose/src/acp/server/manage_sessions.rs b/crates/goose/src/acp/server/manage_sessions.rs index 3c45f2b4793d..8e5163bd17ac 100644 --- a/crates/goose/src/acp/server/manage_sessions.rs +++ b/crates/goose/src/acp/server/manage_sessions.rs @@ -72,47 +72,6 @@ impl GooseAcpAgent { Ok(EmptyResponse {}) } - pub(super) async fn on_set_session_system_prompt( - &self, - req: SetSessionSystemPromptRequest, - ) -> Result { - let session_id = req.session_id.trim(); - if session_id.is_empty() { - return Err( - agent_client_protocol::Error::invalid_params().data("sessionId cannot be empty") - ); - } - - let agent = self.get_session_agent_provider_ready(session_id).await?; - match req.mode { - SessionSystemPromptMode::Set => { - if req.text.trim().is_empty() { - agent.clear_system_prompt_override().await; - } else { - agent.override_system_prompt(req.text).await; - } - } - SessionSystemPromptMode::Append => { - let key = req - .key - .as_deref() - .map(str::trim) - .filter(|key| !key.is_empty()) - .ok_or_else(|| { - agent_client_protocol::Error::invalid_params() - .data("key cannot be empty for append mode") - })?; - if req.text.trim().is_empty() { - agent.remove_system_prompt_extra(key).await; - } else { - agent.extend_system_prompt(key.to_string(), req.text).await; - } - } - } - - Ok(EmptyResponse {}) - } - pub(super) async fn on_delete_session( &self, req: DeleteSessionRequest, diff --git a/crates/goose/src/acp/server/new_session.rs b/crates/goose/src/acp/server/new_session.rs index dba27c844d49..c1a6d5c5354e 100644 --- a/crates/goose/src/acp/server/new_session.rs +++ b/crates/goose/src/acp/server/new_session.rs @@ -96,7 +96,11 @@ impl GooseAcpAgent { meta.insert("extensionResults".to_string(), extension_results); response = response.meta(meta); } - super::send_session_setup_notifications(cx, &goose_session)?; + super::send_session_setup_notifications( + cx, + &goose_session, + self.supports_goose_custom_notifications(), + )?; debug!( target: "perf", sid = %sid, diff --git a/crates/goose/src/agents/platform_extensions/summon.rs b/crates/goose/src/agents/platform_extensions/summon.rs index 2c67d38dd716..320d77fbb1fc 100644 --- a/crates/goose/src/agents/platform_extensions/summon.rs +++ b/crates/goose/src/agents/platform_extensions/summon.rs @@ -16,7 +16,7 @@ use crate::sources::parse_frontmatter; use crate::utils::safe_truncate; use anyhow::Result; use async_trait::async_trait; -use goose_sdk::custom_requests::{SourceEntry, SourceType}; +use goose_sdk_types::custom_requests::{SourceEntry, SourceType}; use rmcp::model::{ CallToolResult, Content, Implementation, InitializeResult, JsonObject, ListToolsResult, Meta, ServerCapabilities, ServerNotification, Tool, diff --git a/crates/goose/src/checks/mod.rs b/crates/goose/src/checks/mod.rs index 172ca72b5106..8f0ac45853d5 100644 --- a/crates/goose/src/checks/mod.rs +++ b/crates/goose/src/checks/mod.rs @@ -7,7 +7,7 @@ use crate::sources::parse_frontmatter; use anyhow::{anyhow, bail, Context, Result}; -use goose_sdk::custom_requests::{SourceEntry, SourceType}; +use goose_sdk_types::custom_requests::{SourceEntry, SourceType}; use serde::Deserialize; use std::collections::{BTreeMap, HashMap}; use std::fs; diff --git a/crates/goose/src/lib.rs b/crates/goose/src/lib.rs index 6bc52a140c3d..4b19247d8239 100644 --- a/crates/goose/src/lib.rs +++ b/crates/goose/src/lib.rs @@ -2,8 +2,7 @@ compile_error!("Features `rustls-tls` and `native-tls` are mutually exclusive"); pub mod acp; -pub use goose_sdk::custom_notifications; -pub use goose_sdk::custom_requests; +pub use goose_sdk_types::{custom_notifications, custom_requests}; pub mod action_required_manager; pub mod agents; pub mod builtin_extension; diff --git a/crates/goose/src/skills/client.rs b/crates/goose/src/skills/client.rs index e26f37dac581..d23bf5c02234 100644 --- a/crates/goose/src/skills/client.rs +++ b/crates/goose/src/skills/client.rs @@ -4,7 +4,7 @@ use crate::agents::extension::PlatformExtensionContext; use crate::agents::mcp_client::{Error, McpClientTrait}; use crate::agents::ToolCallContext; use async_trait::async_trait; -use goose_sdk::custom_requests::{SourceEntry, SourceType}; +use goose_sdk_types::custom_requests::{SourceEntry, SourceType}; use rmcp::model::{ CallToolResult, Content, Implementation, InitializeResult, JsonObject, ListToolsResult, ServerCapabilities, ServerNotification, Tool, diff --git a/crates/goose/src/skills/mod.rs b/crates/goose/src/skills/mod.rs index bc73e86803b8..1645b0d03316 100644 --- a/crates/goose/src/skills/mod.rs +++ b/crates/goose/src/skills/mod.rs @@ -14,7 +14,7 @@ use crate::sources::parse_frontmatter; use agent_client_protocol::Error; use anyhow::Result; use arguments::apply_skill_arguments; -use goose_sdk::custom_requests::{SourceEntry, SourceType}; +use goose_sdk_types::custom_requests::{SourceEntry, SourceType}; use serde::Deserialize; use serde_json::Value; use std::collections::{HashMap, HashSet}; diff --git a/crates/goose/src/slash_commands/skill_slash_command.rs b/crates/goose/src/slash_commands/skill_slash_command.rs index 095cb244ee92..f85554dcb239 100644 --- a/crates/goose/src/slash_commands/skill_slash_command.rs +++ b/crates/goose/src/slash_commands/skill_slash_command.rs @@ -1,6 +1,6 @@ use std::path::Path; -use goose_sdk::custom_requests::{SourceEntry, SourceType}; +use goose_sdk_types::custom_requests::{SourceEntry, SourceType}; use super::types::{SlashCommandEntry, SlashCommandSource}; use super::util::normalize_command_name; @@ -82,7 +82,7 @@ pub(super) fn commands_from_sources(sources: Vec) -> Vec { name: packageJson.name, version: packageJson.version, }, - } satisfies GooseInitializeRequest); + }); monitorConnection(client); return client; diff --git a/ui/sdk/generate-schema.ts b/ui/sdk/generate-schema.ts index 66d7ed673c95..8d67c5d1b356 100644 --- a/ui/sdk/generate-schema.ts +++ b/ui/sdk/generate-schema.ts @@ -239,18 +239,16 @@ async function generateClient(meta: { const handlerFields: string[] = []; const dispatchCases: string[] = []; - const handlerKeys: string[] = []; for (const n of meta.notifications ?? []) { const handlerName = methodToHandlerName(n.method); - handlerKeys.push(handlerName); if (!n.paramsType) { handlerFields.push( ` ${handlerName}?: (params: Record) => Promise;`, ); dispatchCases.push( ` case "${n.method}": { - await ${handlerName}?.(params); + await callbacks.${handlerName}?.(params); return; }`, ); @@ -265,16 +263,12 @@ async function generateClient(meta: { dispatchCases.push( ` case "${n.method}": { const parsed = ${zodName}.parse(params) as ${n.paramsType}; - await ${handlerName}?.(parsed); + await callbacks.${handlerName}?.(parsed); return; }`, ); } - const handlerDestructure = - handlerKeys.length > 0 - ? `const { ${handlerKeys.join(", ")}, ...rest } = callbacks;` - : `const rest = callbacks;`; const handlersInterface = `export interface GooseExtNotifications { ${handlerFields.join("\n")} }`; @@ -282,19 +276,26 @@ ${handlerFields.join("\n")} const dispatcherFn = `export function installGooseExtNotificationDispatcher( callbacks: GooseClientCallbacks, ): Client { - ${handlerDestructure} - const userExtNotification = rest.extNotification; - return { - ...rest, + const dispatcher: Pick = { extNotification: async (method, params) => { switch (method) { ${dispatchCases.join("\n")} default: - await userExtNotification?.(method, params); + await callbacks.extNotification?.(method, params); return; } }, }; + return new Proxy(callbacks, { + get(target, property) { + if (property === "extNotification") { + return dispatcher.extNotification; + } + + const value = Reflect.get(target, property, target); + return typeof value === "function" ? value.bind(target) : value; + }, + }) as Client; }`; const upstreamImportLine = `import type { ${[...upstreamTypeImports].sort().join(", ")} } from "@agentclientprotocol/sdk";`; @@ -322,7 +323,10 @@ ${methodDefs.join("\n")} ${handlersInterface} -export type GooseClientCallbacks = Client & GooseExtNotifications; +export type GooseClientCallbacks = + Omit & + Partial> & + GooseExtNotifications; ${dispatcherFn} `; diff --git a/ui/sdk/package.json b/ui/sdk/package.json index eecc08effcee..882e23d854b4 100644 --- a/ui/sdk/package.json +++ b/ui/sdk/package.json @@ -37,6 +37,8 @@ "build:native:all": "tsx scripts/build-native.ts --all", "generate": "tsx generate-schema.ts", "lint": "tsc --noEmit", + "test": "node --import tsx --test tests/*.test.ts", + "typecheck:test": "tsc -p tsconfig.test.json --noEmit", "format": "prettier --write src/", "check:compat": "node scripts/check-binary-compat.mjs" }, diff --git a/ui/sdk/src/client-capabilities.ts b/ui/sdk/src/client-capabilities.ts new file mode 100644 index 000000000000..8ae2af3b4523 --- /dev/null +++ b/ui/sdk/src/client-capabilities.ts @@ -0,0 +1,8 @@ +import type { GooseMcpHostCapabilities } from "./mcp-apps.js"; + +export interface GooseClientCapabilitiesMeta { + goose?: { + mcpHostCapabilities?: GooseMcpHostCapabilities; + customNotifications?: boolean; + }; +} diff --git a/ui/sdk/src/generated/client.gen.ts b/ui/sdk/src/generated/client.gen.ts index 0d332565a382..9467dd0dcc4d 100644 --- a/ui/sdk/src/generated/client.gen.ts +++ b/ui/sdk/src/generated/client.gen.ts @@ -733,28 +733,37 @@ export interface GooseExtNotifications { ) => Promise; } -export type GooseClientCallbacks = Client & GooseExtNotifications; +export type GooseClientCallbacks = Omit & + Partial> & + GooseExtNotifications; export function installGooseExtNotificationDispatcher( callbacks: GooseClientCallbacks, ): Client { - const { unstable_sessionUpdate, ...rest } = callbacks; - const userExtNotification = rest.extNotification; - return { - ...rest, + const dispatcher: Pick = { extNotification: async (method, params) => { switch (method) { case "_goose/unstable/session/update": { const parsed = zGooseSessionNotification_unstable.parse( params, ) as GooseSessionNotification_unstable; - await unstable_sessionUpdate?.(parsed); + await callbacks.unstable_sessionUpdate?.(parsed); return; } default: - await userExtNotification?.(method, params); + await callbacks.extNotification?.(method, params); return; } }, }; + return new Proxy(callbacks, { + get(target, property) { + if (property === "extNotification") { + return dispatcher.extNotification; + } + + const value = Reflect.get(target, property, target); + return typeof value === "function" ? value.bind(target) : value; + }, + }) as Client; } diff --git a/ui/sdk/src/index.ts b/ui/sdk/src/index.ts index aa4cbe960f54..2d6815adf813 100644 --- a/ui/sdk/src/index.ts +++ b/ui/sdk/src/index.ts @@ -6,6 +6,7 @@ export { } from "./generated/client.gen.js"; export { GooseClient } from "./goose-client.js"; export { createHttpStream } from "./http-stream.js"; +export * from "./client-capabilities.js"; export * from "./mcp-apps.js"; export { diff --git a/ui/sdk/src/mcp-apps.ts b/ui/sdk/src/mcp-apps.ts index 03f3be6ef551..5abbd0387c9c 100644 --- a/ui/sdk/src/mcp-apps.ts +++ b/ui/sdk/src/mcp-apps.ts @@ -1,7 +1,3 @@ -import type { - Implementation, - InitializeRequest, -} from "@agentclientprotocol/sdk"; import { RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/app-bridge"; import type { McpUiAppResourceConfig, @@ -68,19 +64,6 @@ export interface GooseToolCallUpdateMeta { [key: string]: unknown; } -export interface GooseClientMeta { - goose: { - mcpHostCapabilities: GooseMcpHostCapabilities; - }; -} - -export type GooseInitializeRequest = InitializeRequest & { - clientCapabilities: NonNullable & { - _meta: GooseClientMeta; - }; - clientInfo: Implementation; -}; - export const DEFAULT_GOOSE_MCP_HOST_CAPABILITIES: GooseMcpHostCapabilities = { extensions: { [GOOSE_MCP_UI_EXTENSION_ID]: { diff --git a/ui/sdk/tests/client-callbacks.test.ts b/ui/sdk/tests/client-callbacks.test.ts new file mode 100644 index 000000000000..e2b4bdc0e8d2 --- /dev/null +++ b/ui/sdk/tests/client-callbacks.test.ts @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { installGooseExtNotificationDispatcher } from "../src/generated/client.gen.ts"; +import type { GooseSessionNotification_unstable } from "../src/generated/types.gen.ts"; +import type { + RequestPermissionRequest, + RequestPermissionResponse, + SessionNotification, +} from "@agentclientprotocol/sdk"; + +class ClassBackedCallbacks { + #events: string[] = []; + + get events(): string[] { + return this.#events; + } + + async requestPermission( + _params: RequestPermissionRequest, + ): Promise { + this.#events.push("requestPermission"); + return { outcome: { outcome: "cancelled" } }; + } + + async sessionUpdate(_params: SessionNotification): Promise { + this.#events.push("sessionUpdate"); + } + + async extNotification( + method: string, + _params: Record, + ): Promise { + this.#events.push(`extNotification:${method}`); + } + + async unstable_sessionUpdate( + notification: GooseSessionNotification_unstable, + ): Promise { + this.#events.push( + `unstable_sessionUpdate:${notification.update.sessionUpdate}`, + ); + } +} + +class MinimalCallbacks { + async requestPermission( + _params: RequestPermissionRequest, + ): Promise { + return { outcome: { outcome: "cancelled" } }; + } + + async sessionUpdate(_params: SessionNotification): Promise {} +} + +test("dispatcher preserves class-backed callback receivers", async () => { + const callbacks = new ClassBackedCallbacks(); + const client = installGooseExtNotificationDispatcher(callbacks); + + await client.requestPermission({} as RequestPermissionRequest); + await client.sessionUpdate({} as SessionNotification); + await client.extNotification!("_goose/unstable/session/update", { + sessionId: "session-1", + update: { + sessionUpdate: "status_message", + status: { + type: "notice", + message: "ready", + }, + }, + }); + await client.extNotification!("example/unknown", {}); + + assert.deepEqual(callbacks.events, [ + "requestPermission", + "sessionUpdate", + "unstable_sessionUpdate:status_message", + "extNotification:example/unknown", + ]); +}); + +test("raw extNotification is optional", async () => { + const client = installGooseExtNotificationDispatcher(new MinimalCallbacks()); + + await client.extNotification!("example/unknown", {}); +}); diff --git a/ui/sdk/tsconfig.test.json b/ui/sdk/tsconfig.test.json new file mode 100644 index 000000000000..b605b7635a66 --- /dev/null +++ b/ui/sdk/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "allowImportingTsExtensions": true, + "noEmit": true, + "rootDir": "." + }, + "include": ["src", "tests"] +}