feat(acp): add session/set_config and stabilize list, delete and close (#7984)

Signed-off-by: Adrian Cole <adrian@tetrate.io>
This commit is contained in:
Adrian Cole
2026-03-22 18:33:26 -04:00
committed by GitHub
parent 6afaeb8432
commit 1426c8bfa0
32 changed files with 2539 additions and 1100 deletions
Generated
+82 -105
View File
@@ -40,16 +40,16 @@ dependencies = [
[[package]]
name = "agent-client-protocol-schema"
version = "0.10.8"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44bc1fef9c32f03bce2ab44af35b6f483bfd169bf55cc59beeb2e3b1a00ae4d1"
checksum = "96daddd0d00f2eab88f8099d38190881bf8d6c5e46b6fa21751f482775d0dba7"
dependencies = [
"anyhow",
"derive_more",
"schemars 1.2.1",
"serde",
"serde_json",
"strum",
"strum 0.28.0",
]
[[package]]
@@ -225,9 +225,9 @@ dependencies = [
[[package]]
name = "arc-swap"
version = "1.8.2"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f3647c145568cec02c42054e07bdf9a5a698e15b466fb2341bfc393cd24aa5"
checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6"
dependencies = [
"rustversion",
]
@@ -443,9 +443,9 @@ dependencies = [
[[package]]
name = "aws-lc-fips-sys"
version = "0.13.12"
version = "0.13.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ed8cd42adddefbdb8507fb7443fa9b666631078616b78f70ed22117b5c27d90"
checksum = "f8bce4948d2520386c6d92a6ea2d472300257702242e5a1d01d6add52bd2e7c1"
dependencies = [
"bindgen",
"cc",
@@ -457,9 +457,9 @@ dependencies = [
[[package]]
name = "aws-lc-rs"
version = "1.16.1"
version = "1.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf"
checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
dependencies = [
"aws-lc-fips-sys",
"aws-lc-sys",
@@ -469,9 +469,9 @@ dependencies = [
[[package]]
name = "aws-lc-sys"
version = "0.38.0"
version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e"
checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a"
dependencies = [
"bindgen",
"cc",
@@ -2983,8 +2983,8 @@ dependencies = [
"proc-macro2",
"quote",
"stringcase",
"strum",
"strum_macros",
"strum 0.27.2",
"strum_macros 0.27.2",
"syn 2.0.117",
"syn-match",
"thiserror 2.0.18",
@@ -4139,15 +4139,6 @@ dependencies = [
"slab",
]
[[package]]
name = "fxhash"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
dependencies = [
"byteorder",
]
[[package]]
name = "gemm"
version = "0.18.2"
@@ -4533,6 +4524,7 @@ dependencies = [
"encoding_rs",
"env-lock",
"etcetera 0.11.0",
"fs-err",
"fs2",
"futures",
"goose-test-support",
@@ -4564,7 +4556,7 @@ dependencies = [
"rayon",
"regex",
"reqwest 0.13.2",
"rmcp 1.2.0",
"rmcp",
"rubato",
"sacp",
"schemars 1.2.1",
@@ -4577,7 +4569,7 @@ dependencies = [
"shell-words",
"shellexpand",
"sqlx",
"strum",
"strum 0.27.2",
"symphonia",
"sys-info",
"tempfile",
@@ -4634,12 +4626,13 @@ dependencies = [
"goose-test-support",
"http-body-util",
"regex",
"rmcp 1.2.0",
"rmcp",
"sacp",
"schemars 1.2.1",
"serde",
"serde_json",
"strum",
"sqlx",
"strum 0.27.2",
"tempfile",
"test-case",
"tokio",
@@ -4688,7 +4681,7 @@ dependencies = [
"rand 0.8.5",
"regex",
"reqwest 0.13.2",
"rmcp 1.2.0",
"rmcp",
"rustyline",
"serde",
"serde_json",
@@ -4696,7 +4689,7 @@ dependencies = [
"sha2",
"shlex",
"sigstore-verification",
"strum",
"strum 0.27.2",
"tar",
"tempfile",
"test-case",
@@ -4709,7 +4702,7 @@ dependencies = [
"urlencoding",
"webbrowser",
"winapi",
"zip 8.2.0",
"zip 8.3.1",
]
[[package]]
@@ -4727,7 +4720,7 @@ dependencies = [
"lopdf",
"once_cell",
"reqwest 0.13.2",
"rmcp 1.2.0",
"rmcp",
"schemars 1.2.1",
"serde",
"serde_json",
@@ -4763,7 +4756,7 @@ dependencies = [
"rand 0.9.2",
"rcgen",
"reqwest 0.13.2",
"rmcp 1.2.0",
"rmcp",
"rustls 0.23.37",
"serde",
"serde_json",
@@ -4802,7 +4795,7 @@ dependencies = [
"axum 0.7.9",
"env-lock",
"opentelemetry",
"rmcp 1.2.0",
"rmcp",
"serde_json",
"tokio",
]
@@ -5660,9 +5653,9 @@ dependencies = [
[[package]]
name = "itoa"
version = "1.0.17"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "ixdtf"
@@ -6125,9 +6118,9 @@ dependencies = [
[[package]]
name = "linux-keyutils"
version = "0.2.4"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "761e49ec5fd8a5a463f9b84e877c373d888935b71c6be78f3767fe2ae6bed18e"
checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590"
dependencies = [
"bitflags 2.11.0",
"libc",
@@ -6882,8 +6875,8 @@ dependencies = [
"regex",
"serde",
"serde_json",
"strum",
"strum_macros",
"strum 0.27.2",
"strum_macros 0.27.2",
"thiserror 2.0.18",
]
@@ -7316,7 +7309,7 @@ dependencies = [
"deno_error",
"pctx_config",
"pctx_registry",
"rmcp 1.2.0",
"rmcp",
"serde",
"serde_json",
"thiserror 2.0.18",
@@ -7382,7 +7375,7 @@ dependencies = [
"opentelemetry-otlp",
"opentelemetry_sdk",
"reqwest 0.13.2",
"rmcp 1.2.0",
"rmcp",
"serde",
"serde_json",
"shlex",
@@ -7435,7 +7428,7 @@ checksum = "9a49c948ffc8c07357e76b2e0008503c9fafaf49d91fd7e00e672bbd7aabd157"
dependencies = [
"deno_error",
"pctx_config",
"rmcp 1.2.0",
"rmcp",
"serde_json",
"thiserror 2.0.18",
"tokio",
@@ -8053,9 +8046,9 @@ dependencies = [
[[package]]
name = "pulldown-cmark"
version = "0.13.1"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83c41efbf8f90ac44de7f3a868f0867851d261b56291732d0cbf7cceaaeb55a6"
checksum = "14104c5a24d9bcf7eb2c24753e0f49fe14555d8bd565ea3d38e4b4303267259d"
dependencies = [
"bitflags 2.11.0",
"getopts",
@@ -8643,28 +8636,6 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rmcp"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "528d42f8176e6e5e71ea69182b17d1d0a19a6b3b894b564678b74cd7cab13cfa"
dependencies = [
"async-trait",
"base64 0.22.1",
"chrono",
"futures",
"pastey",
"pin-project-lite",
"rmcp-macros 0.12.0",
"schemars 1.2.1",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "rmcp"
version = "1.2.0"
@@ -8685,7 +8656,7 @@ dependencies = [
"process-wrap",
"rand 0.10.0",
"reqwest 0.13.2",
"rmcp-macros 1.2.0",
"rmcp-macros",
"schemars 1.2.1",
"serde",
"serde_json",
@@ -8700,19 +8671,6 @@ dependencies = [
"uuid",
]
[[package]]
name = "rmcp-macros"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3f81daaa494eb8e985c9462f7d6ce1ab05e5299f48aafd76cdd3d8b060e6f59"
dependencies = [
"darling 0.23.0",
"proc-macro2",
"quote",
"serde_json",
"syn 2.0.117",
]
[[package]]
name = "rmcp-macros"
version = "1.2.0"
@@ -8887,7 +8845,7 @@ dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki 0.103.9",
"rustls-webpki 0.103.10",
"subtle",
"zeroize",
]
@@ -8928,7 +8886,7 @@ dependencies = [
"rustls 0.23.37",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki 0.103.9",
"rustls-webpki 0.103.10",
"security-framework 3.7.0",
"security-framework-sys",
"webpki-root-certs",
@@ -8953,9 +8911,9 @@ dependencies = [
[[package]]
name = "rustls-webpki"
version = "0.103.9"
version = "0.103.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
dependencies = [
"aws-lc-rs",
"ring",
@@ -9011,18 +8969,17 @@ checksum = "dd29631678d6fb0903b69223673e122c32e9ae559d0960a38d574695ebc0ea15"
[[package]]
name = "sacp"
version = "10.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "704f40d3c269b30229c34093b658ec80c4fac103281654b3965249c592dd6fa6"
version = "11.0.0"
source = "git+https://github.com/agentclientprotocol/symposium-acp?rev=14086c9#14086c93482ea0618ea6ca7a4d1288953569147e"
dependencies = [
"agent-client-protocol-schema",
"anyhow",
"boxfnonce",
"futures",
"futures-concurrency",
"fxhash",
"jsonrpcmsg",
"rmcp 0.12.0",
"rmcp",
"rustc-hash 2.1.1",
"sacp-derive",
"schemars 1.2.1",
"serde",
@@ -9036,9 +8993,8 @@ dependencies = [
[[package]]
name = "sacp-derive"
version = "10.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92150f9246c01d501855e34469810a82adc27c416c8d8e21665567f8cd966f29"
version = "11.0.0"
source = "git+https://github.com/agentclientprotocol/symposium-acp?rev=14086c9#14086c93482ea0618ea6ca7a4d1288953569147e"
dependencies = [
"proc-macro2",
"quote",
@@ -9598,7 +9554,7 @@ dependencies = [
"ring",
"rsa",
"rustls-pki-types",
"rustls-webpki 0.103.9",
"rustls-webpki 0.103.10",
"scrypt",
"serde",
"serde_json",
@@ -10167,7 +10123,16 @@ version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
dependencies = [
"strum_macros",
"strum_macros 0.27.2",
]
[[package]]
name = "strum"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
dependencies = [
"strum_macros 0.28.0",
]
[[package]]
@@ -10182,6 +10147,18 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "strum_macros"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "subtle"
version = "2.6.1"
@@ -10908,9 +10885,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "tar"
version = "0.4.44"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a"
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
dependencies = [
"filetime",
"libc",
@@ -11805,9 +11782,9 @@ dependencies = [
[[package]]
name = "tree-sitter-rust"
version = "0.24.0"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b9b18034c684a2420722be8b2a91c9c44f2546b631c039edf575ccba8c61be1"
checksum = "f715f73a0687261ddb686f0d64a1e5af57bd199c4d12be5fdda6676ce1885bf9"
dependencies = [
"cc",
"tree-sitter-language",
@@ -13355,18 +13332,18 @@ dependencies = [
[[package]]
name = "zerocopy"
version = "0.8.42"
version = "0.8.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3"
checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.42"
version = "0.8.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f"
checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89"
dependencies = [
"proc-macro2",
"quote",
@@ -13499,9 +13476,9 @@ dependencies = [
[[package]]
name = "zip"
version = "8.2.0"
version = "8.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b680f2a0cd479b4cff6e1233c483fdead418106eae419dc60200ae9850f6d004"
checksum = "5c546feb4481b0fbafb4ef0d79b6204fc41c6f9884b1b73b1d73f82442fc0845"
dependencies = [
"crc32fast",
"flate2",
@@ -13612,9 +13589,9 @@ dependencies = [
[[package]]
name = "zune-jpeg"
version = "0.5.13"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec5f41c76397b7da451efd19915684f727d7e1d516384ca6bd0ec43ec94de23c"
checksum = "0b7a1c0af6e5d8d1363f4994b7a091ccf963d8b694f7da5b0b9cceb82da2c0a6"
dependencies = [
"zune-core",
]
+5 -1
View File
@@ -20,7 +20,8 @@ string_slice = "warn"
[workspace.dependencies]
rmcp = { version = "1.2.0", features = ["schemars", "auth"] }
sacp = "10.1.0"
agent-client-protocol-schema = { version = "0.11", features = ["unstable"] }
sacp = "11.0.0"
anyhow = "1.0"
async-stream = "0.3"
async-trait = "0.1"
@@ -90,6 +91,9 @@ tree-sitter-typescript = "0.23"
[patch.crates-io]
v8 = { path = "vendor/v8" }
# TODO: switch to crates.io release once it includes the unstable feature and updates org from symposium-dev to agentclientprotocol
sacp = { git = "https://github.com/agentclientprotocol/symposium-acp", rev = "14086c9" }
sacp-derive = { git = "https://github.com/agentclientprotocol/symposium-acp", rev = "14086c9" }
# TODO: switch to released version in opentelemetry 0.32.0
# https://github.com/open-telemetry/opentelemetry-rust/issues/3408
opentelemetry = { git = "https://github.com/open-telemetry/opentelemetry-rust", rev = "345cd74a" }
+6 -6
View File
@@ -10,7 +10,7 @@ use syn::{
/// Generates two methods on the impl:
///
/// 1. `handle_custom_request` — a dispatcher that:
/// - Prefixes each method name with `_goose/`
/// - Uses each annotation string as the method name (include `_goose/` for goose-only methods)
/// - Parses JSON params into the handler's typed parameter (if any)
/// - Serializes the handler's return value to JSON
///
@@ -79,13 +79,13 @@ pub fn custom_methods(_attr: TokenStream, item: TokenStream) -> TokenStream {
let arms: Vec<_> = routes
.iter()
.map(|route| {
let full_method = format!("_goose/{}", route.method_name);
let method = &route.method_name;
let fn_ident = &route.fn_ident;
match &route.param_type {
Some(_) => {
quote! {
#full_method => {
#method => {
let req = serde_json::from_value(params)
.map_err(|e| sacp::Error::invalid_params().data(e.to_string()))?;
let result = self.#fn_ident(req).await?;
@@ -96,7 +96,7 @@ pub fn custom_methods(_attr: TokenStream, item: TokenStream) -> TokenStream {
}
None => {
quote! {
#full_method => {
#method => {
let result = self.#fn_ident().await?;
serde_json::to_value(&result)
.map_err(|e| sacp::Error::internal_error().data(e.to_string()))
@@ -111,7 +111,7 @@ pub fn custom_methods(_attr: TokenStream, item: TokenStream) -> TokenStream {
let schema_entries: Vec<_> = routes
.iter()
.map(|route| {
let full_method = format!("_goose/{}", route.method_name);
let method = &route.method_name;
let params_expr = if let Some(pt) = &route.param_type {
if is_json_value(pt) {
@@ -157,7 +157,7 @@ pub fn custom_methods(_attr: TokenStream, item: TokenStream) -> TokenStream {
quote! {
crate::custom_requests::CustomMethodSchema {
method: #full_method.to_string(),
method: #method.to_string(),
params_schema: #params_expr,
params_type_name: #params_name_expr,
response_schema: #response_expr,
+3 -2
View File
@@ -26,8 +26,8 @@ workspace = true
goose = { path = "../goose", default-features = false }
goose-mcp = { path = "../goose-mcp" }
rmcp = { workspace = true }
sacp = { workspace = true }
agent-client-protocol-schema = { version = "0.10", features = ["unstable"] }
sacp = { workspace = true, features = ["unstable"] }
agent-client-protocol-schema = { workspace = true }
async-trait = { workspace = true }
anyhow = { workspace = true }
tokio = { workspace = true }
@@ -60,6 +60,7 @@ tempfile = { workspace = true }
test-case = { workspace = true }
axum = { workspace = true }
rmcp = { workspace = true, features = ["transport-streamable-http-server"] }
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "sqlite"] }
[package.metadata.cargo-machete]
# Used to provide extras imports for sacp
+8 -13
View File
@@ -1,35 +1,30 @@
{
"methods": [
{
"method": "extensions/add",
"method": "_goose/extensions/add",
"requestType": "AddExtensionRequest",
"responseType": "EmptyResponse"
},
{
"method": "extensions/remove",
"method": "_goose/extensions/remove",
"requestType": "RemoveExtensionRequest",
"responseType": "EmptyResponse"
},
{
"method": "tools",
"method": "_goose/tools",
"requestType": "GetToolsRequest",
"responseType": "GetToolsResponse"
},
{
"method": "resource/read",
"method": "_goose/resource/read",
"requestType": "ReadResourceRequest",
"responseType": "ReadResourceResponse"
},
{
"method": "working_dir/update",
"method": "_goose/working_dir/update",
"requestType": "UpdateWorkingDirRequest",
"responseType": "EmptyResponse"
},
{
"method": "session/list",
"requestType": null,
"responseType": "ListSessionsResponse"
},
{
"method": "session/get",
"requestType": "GetSessionRequest",
@@ -41,17 +36,17 @@
"responseType": "EmptyResponse"
},
{
"method": "session/export",
"method": "_goose/session/export",
"requestType": "ExportSessionRequest",
"responseType": "ExportSessionResponse"
},
{
"method": "session/import",
"method": "_goose/session/import",
"requestType": "ImportSessionRequest",
"responseType": "ImportSessionResponse"
},
{
"method": "config/extensions",
"method": "_goose/config/extensions",
"requestType": null,
"responseType": "GetExtensionsResponse"
}
+45 -68
View File
@@ -5,7 +5,7 @@
"AddExtensionRequest": {
"type": "object",
"properties": {
"session_id": {
"sessionId": {
"type": "string"
},
"config": {
@@ -13,12 +13,12 @@
}
},
"required": [
"session_id",
"sessionId",
"config"
],
"description": "Add an extension to an active session.\nMethod: `_agent/extensions/add`",
"description": "Add an extension to an active session.",
"x-side": "agent",
"x-method": "extensions/add"
"x-method": "_goose/extensions/add"
},
"EmptyResponse": {
"type": "object",
@@ -28,7 +28,7 @@
"RemoveExtensionRequest": {
"type": "object",
"properties": {
"session_id": {
"sessionId": {
"type": "string"
},
"name": {
@@ -36,26 +36,26 @@
}
},
"required": [
"session_id",
"sessionId",
"name"
],
"description": "Remove an extension from an active session.\nMethod: `_agent/extensions/remove`",
"description": "Remove an extension from an active session.",
"x-side": "agent",
"x-method": "extensions/remove"
"x-method": "_goose/extensions/remove"
},
"GetToolsRequest": {
"type": "object",
"properties": {
"session_id": {
"sessionId": {
"type": "string"
}
},
"required": [
"session_id"
"sessionId"
],
"description": "List all tools available in a session.\nMethod: `_agent/tools`",
"description": "List all tools available in a session.",
"x-side": "agent",
"x-method": "tools"
"x-method": "_goose/tools"
},
"GetToolsResponse": {
"type": "object",
@@ -70,29 +70,29 @@
"tools"
],
"x-side": "agent",
"x-method": "tools"
"x-method": "_goose/tools"
},
"ReadResourceRequest": {
"type": "object",
"properties": {
"session_id": {
"sessionId": {
"type": "string"
},
"uri": {
"type": "string"
},
"extension_name": {
"extensionName": {
"type": "string"
}
},
"required": [
"session_id",
"sessionId",
"uri",
"extension_name"
"extensionName"
],
"description": "Read a resource from an extension.\nMethod: `_agent/resource/read`",
"description": "Read a resource from an extension.",
"x-side": "agent",
"x-method": "resource/read"
"x-method": "_goose/resource/read"
},
"ReadResourceResponse": {
"type": "object",
@@ -105,56 +105,41 @@
"result"
],
"x-side": "agent",
"x-method": "resource/read"
"x-method": "_goose/resource/read"
},
"UpdateWorkingDirRequest": {
"type": "object",
"properties": {
"session_id": {
"sessionId": {
"type": "string"
},
"working_dir": {
"workingDir": {
"type": "string"
}
},
"required": [
"session_id",
"working_dir"
"sessionId",
"workingDir"
],
"description": "Update the working directory for a session.\nMethod: `_agent/working_dir/update`",
"description": "Update the working directory for a session.",
"x-side": "agent",
"x-method": "working_dir/update"
},
"ListSessionsResponse": {
"type": "object",
"properties": {
"sessions": {
"type": "array",
"items": true
}
},
"required": [
"sessions"
],
"description": "List all sessions.\nMethod: `_session/list`",
"x-side": "agent",
"x-method": "session/list"
"x-method": "_goose/working_dir/update"
},
"GetSessionRequest": {
"type": "object",
"properties": {
"session_id": {
"sessionId": {
"type": "string"
},
"include_messages": {
"includeMessages": {
"type": "boolean",
"default": false
}
},
"required": [
"session_id"
"sessionId"
],
"description": "Get a session by ID.\nMethod: `_session/get`",
"description": "Get a session by ID.",
"x-side": "agent",
"x-method": "session/get"
},
@@ -175,30 +160,30 @@
"DeleteSessionRequest": {
"type": "object",
"properties": {
"session_id": {
"sessionId": {
"type": "string"
}
},
"required": [
"session_id"
"sessionId"
],
"description": "Delete a session.\nMethod: `_session/delete`",
"description": "Delete a session.",
"x-side": "agent",
"x-method": "session/delete"
},
"ExportSessionRequest": {
"type": "object",
"properties": {
"session_id": {
"sessionId": {
"type": "string"
}
},
"required": [
"session_id"
"sessionId"
],
"description": "Export a session as a JSON string.\nMethod: `_session/export`",
"description": "Export a session as a JSON string.",
"x-side": "agent",
"x-method": "session/export"
"x-method": "_goose/session/export"
},
"ExportSessionResponse": {
"type": "object",
@@ -211,7 +196,7 @@
"data"
],
"x-side": "agent",
"x-method": "session/export"
"x-method": "_goose/session/export"
},
"ImportSessionRequest": {
"type": "object",
@@ -223,9 +208,9 @@
"required": [
"data"
],
"description": "Import a session from a JSON string.\nMethod: `_session/import`",
"description": "Import a session from a JSON string.",
"x-side": "agent",
"x-method": "session/import"
"x-method": "_goose/session/import"
},
"ImportSessionResponse": {
"type": "object",
@@ -238,7 +223,7 @@
"session"
],
"x-side": "agent",
"x-method": "session/import"
"x-method": "_goose/session/import"
},
"GetExtensionsResponse": {
"type": "object",
@@ -259,9 +244,9 @@
"extensions",
"warnings"
],
"description": "List configured extensions and any warnings.\nMethod: `_config/extensions`",
"description": "List configured extensions and any warnings.",
"x-side": "agent",
"x-method": "config/extensions"
"x-method": "_goose/config/extensions"
},
"ExtRequest": {
"properties": {
@@ -326,7 +311,7 @@
"$ref": "#/$defs/GetSessionRequest"
}
],
"description": "Params for _goose/session/get",
"description": "Params for session/get",
"title": "GetSessionRequest"
},
{
@@ -335,7 +320,7 @@
"$ref": "#/$defs/DeleteSessionRequest"
}
],
"description": "Params for _goose/session/delete",
"description": "Params for session/delete",
"title": "DeleteSessionRequest"
},
{
@@ -410,14 +395,6 @@
],
"title": "ReadResourceResponse"
},
{
"allOf": [
{
"$ref": "#/$defs/ListSessionsResponse"
}
],
"title": "ListSessionsResponse"
},
{
"allOf": [
{
@@ -17,15 +17,10 @@ fn main() {
.map(|(k, v)| (k, serde_json::to_value(v).unwrap_or(json!({}))))
.collect();
// Strip the `_goose/` prefix to get the bare method name for x-method.
fn bare_method(full: &str) -> &str {
full.strip_prefix("_goose/").unwrap_or(full)
}
// Track which types map to which methods so we can detect shared types.
let mut type_methods: HashMap<String, Vec<String>> = HashMap::new();
for m in &methods {
let method = bare_method(&m.method).to_string();
let method = m.method.clone();
if let Some(name) = &m.params_type_name {
type_methods
.entry(name.clone())
@@ -172,7 +167,7 @@ fn main() {
.iter()
.map(|m| {
json!({
"method": bare_method(&m.method),
"method": &m.method,
"requestType": m.params_type_name,
"responseType": m.response_type_name,
})
+8
View File
@@ -21,6 +21,7 @@ pub struct CustomMethodSchema {
/// Add an extension to an active session.
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct AddExtensionRequest {
pub session_id: String,
/// Extension configuration (see ExtensionConfig variants: Stdio, StreamableHttp, Builtin, Platform).
@@ -29,6 +30,7 @@ pub struct AddExtensionRequest {
/// Remove an extension from an active session.
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct RemoveExtensionRequest {
pub session_id: String,
pub name: String,
@@ -36,6 +38,7 @@ pub struct RemoveExtensionRequest {
/// List all tools available in a session.
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct GetToolsRequest {
pub session_id: String,
}
@@ -48,6 +51,7 @@ pub struct GetToolsResponse {
/// Read a resource from an extension.
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct ReadResourceRequest {
pub session_id: String,
pub uri: String,
@@ -62,6 +66,7 @@ pub struct ReadResourceResponse {
/// Update the working directory for a session.
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct UpdateWorkingDirRequest {
pub session_id: String,
pub working_dir: String,
@@ -69,6 +74,7 @@ pub struct UpdateWorkingDirRequest {
/// Get a session by ID.
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct GetSessionRequest {
pub session_id: String,
#[serde(default)]
@@ -84,12 +90,14 @@ pub struct GetSessionResponse {
/// Delete a session.
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct DeleteSessionRequest {
pub session_id: String,
}
/// Export a session as a JSON string.
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct ExportSessionRequest {
pub session_id: String,
}
+9 -9
View File
@@ -10,12 +10,12 @@ use goose::agents::platform_extensions::developer::shell::{ShellParams, OUTPUT_L
use goose::agents::platform_extensions::developer::DeveloperClient;
use rmcp::model::{CallToolResult, Content as RmcpContent, Tool, ToolAnnotations};
use sacp::schema::{
CreateTerminalRequest, Diff, KillTerminalCommandRequest, ReadTextFileRequest,
ReleaseTerminalRequest, SessionId, SessionNotification, SessionUpdate, Terminal,
TerminalOutputRequest, ToolCallContent, ToolCallId, ToolCallLocation, ToolCallUpdate,
ToolCallUpdateFields, ToolKind, WaitForTerminalExitRequest, WriteTextFileRequest,
CreateTerminalRequest, Diff, KillTerminalRequest, ReadTextFileRequest, ReleaseTerminalRequest,
SessionId, SessionNotification, SessionUpdate, Terminal, TerminalOutputRequest,
ToolCallContent, ToolCallId, ToolCallLocation, ToolCallUpdate, ToolCallUpdateFields, ToolKind,
WaitForTerminalExitRequest, WriteTextFileRequest,
};
use sacp::{AgentToClient, JrConnectionCx};
use sacp::{Client, ConnectionTo};
use schemars::schema_for;
use std::path::Path;
use std::sync::Arc;
@@ -24,7 +24,7 @@ use tokio::time::timeout;
use tokio_util::sync::CancellationToken;
async fn acp_read_text_file(
cx: &JrConnectionCx<AgentToClient>,
cx: &ConnectionTo<Client>,
session_id: &SessionId,
path: &Path,
line: Option<u32>,
@@ -46,7 +46,7 @@ async fn acp_read_text_file(
}
async fn acp_write_text_file(
cx: &JrConnectionCx<AgentToClient>,
cx: &ConnectionTo<Client>,
session_id: &SessionId,
path: &Path,
content: &str,
@@ -62,7 +62,7 @@ async fn acp_write_text_file(
pub(crate) struct AcpTools {
pub(crate) inner: Arc<dyn McpClientTrait>,
pub(crate) cx: JrConnectionCx<AgentToClient>,
pub(crate) cx: ConnectionTo<Client>,
pub(crate) session_id: SessionId,
pub(crate) fs_read: bool,
pub(crate) fs_write: bool,
@@ -335,7 +335,7 @@ impl AcpTools {
Err(_) => {
let _ = self
.cx
.send_request(KillTerminalCommandRequest::new(
.send_request(KillTerminalRequest::new(
self.session_id.clone(),
terminal_id.clone(),
))
+387 -210
View File
@@ -26,30 +26,37 @@ use goose::session::{Session, SessionManager};
use goose_acp_macros::custom_methods;
use rmcp::model::{CallToolResult, RawContent, ResourceContents, Role};
use sacp::schema::{
AgentCapabilities, AuthMethod, AuthenticateRequest, AuthenticateResponse, BlobResourceContents,
CancelNotification, Content, ContentBlock, ContentChunk, CurrentModeUpdate, EmbeddedResource,
EmbeddedResourceResource, FileSystemCapability, ImageContent, InitializeRequest,
InitializeResponse, ListSessionsResponse, LoadSessionRequest, LoadSessionResponse,
McpCapabilities, McpServer, ModelId, ModelInfo, NewSessionRequest, NewSessionResponse,
PermissionOption, PermissionOptionKind, PromptCapabilities, PromptRequest, PromptResponse,
RequestPermissionOutcome, RequestPermissionRequest, ResourceLink, SessionCapabilities,
SessionId, SessionInfo, SessionListCapabilities, SessionMode, SessionModeId, SessionModeState,
SessionModelState, SessionNotification, SessionUpdate, SetSessionModeRequest,
SetSessionModeResponse, SetSessionModelRequest, SetSessionModelResponse, StopReason,
TextContent, TextResourceContents, ToolCall, ToolCallContent, ToolCallId, ToolCallLocation,
ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, ToolKind,
AgentCapabilities, AuthMethod, AuthMethodAgent, AuthenticateRequest, AuthenticateResponse,
BlobResourceContents, CancelNotification, CloseSessionRequest, CloseSessionResponse,
ConfigOptionUpdate, Content, ContentBlock, ContentChunk, CurrentModeUpdate, EmbeddedResource,
EmbeddedResourceResource, FileSystemCapabilities, ImageContent, InitializeRequest,
InitializeResponse, ListSessionsRequest, ListSessionsResponse, LoadSessionRequest,
LoadSessionResponse, McpCapabilities, McpServer, ModelId, ModelInfo, NewSessionRequest,
NewSessionResponse, PermissionOption, PermissionOptionKind, PromptCapabilities, PromptRequest,
PromptResponse, RequestPermissionOutcome, RequestPermissionRequest, ResourceLink,
SessionCapabilities, SessionCloseCapabilities, SessionConfigOption,
SessionConfigOptionCategory, SessionConfigSelectOption, SessionId, SessionInfo,
SessionListCapabilities, SessionMode, SessionModeId, SessionModeState, SessionModelState,
SessionNotification, SessionUpdate, SetSessionConfigOptionRequest,
SetSessionConfigOptionResponse, SetSessionModeRequest, SetSessionModeResponse,
SetSessionModelRequest, SetSessionModelResponse, StopReason, TextContent, TextResourceContents,
ToolCall, ToolCallContent, ToolCallId, ToolCallLocation, ToolCallStatus, ToolCallUpdate,
ToolCallUpdateFields, ToolKind,
};
use sacp::util::MatchDispatchFrom;
use sacp::{
Agent as SacpAgent, ByteStreams, Client, ConnectionTo, Dispatch, HandleDispatchFrom, Handled,
Responder,
};
use sacp::{AgentToClient, ByteStreams, Handled, JrConnectionCx, JrMessageHandler, MessageCx};
use std::collections::HashMap;
use std::sync::Arc;
use strum::{EnumMessage, VariantNames};
use tokio::sync::{Mutex, OnceCell};
use tokio_util::compat::{TokioAsyncReadCompatExt as _, TokioAsyncWriteCompatExt as _};
use tokio_util::sync::CancellationToken;
use tracing::{debug, error, info, warn};
use url::Url;
// Agent binds provider, extensions, and permission channels to a single session.
// ACP has no session/close, so sessions accumulate until transport closes.
struct GooseAcpSession {
agent: Arc<Agent>,
messages: Conversation,
@@ -61,7 +68,7 @@ pub struct GooseAcpAgent {
sessions: Arc<Mutex<HashMap<String, GooseAcpSession>>>,
provider_factory: ProviderConstructor,
builtins: Vec<String>,
client_fs_capabilities: OnceCell<FileSystemCapability>,
client_fs_capabilities: OnceCell<FileSystemCapabilities>,
client_terminal: OnceCell<bool>,
config_dir: std::path::PathBuf,
session_manager: Arc<SessionManager>,
@@ -306,25 +313,22 @@ fn builtin_to_extension_config(name: &str) -> ExtensionConfig {
}
}
async fn build_model_state(provider: &dyn Provider, current_model: &str) -> SessionModelState {
let models = match provider.fetch_recommended_models().await {
Ok(models) => models,
Err(e) => {
warn!(error = %e, "failed to fetch models, model selection will be unavailable");
vec![]
}
};
SessionModelState::new(
ModelId::new(current_model),
async fn build_model_state(provider: &dyn Provider) -> Result<SessionModelState, sacp::Error> {
let models = provider
.fetch_recommended_models()
.await
.map_err(|e| sacp::Error::internal_error().data(e.to_string()))?;
let current_model = &provider.get_model_config().model_name;
Ok(SessionModelState::new(
ModelId::new(current_model.as_str()),
models
.iter()
.map(|name| ModelInfo::new(ModelId::new(&**name), &**name))
.collect(),
)
))
}
fn build_mode_state(current_mode: GooseMode) -> Result<SessionModeState, sacp::Error> {
use strum::{EnumMessage, VariantNames};
let mut available = Vec::with_capacity(GooseMode::VARIANTS.len());
for &name in GooseMode::VARIANTS {
let goose_mode: GooseMode = name.parse().map_err(|_| {
@@ -341,11 +345,47 @@ fn build_mode_state(current_mode: GooseMode) -> Result<SessionModeState, sacp::E
))
}
fn build_config_options(
mode_state: &SessionModeState,
model_state: &SessionModelState,
) -> Vec<SessionConfigOption> {
let mode_options: Vec<SessionConfigSelectOption> = mode_state
.available_modes
.iter()
.map(|m| {
SessionConfigSelectOption::new(m.id.0.clone(), m.name.clone())
.description(m.description.clone())
})
.collect();
let model_options: Vec<SessionConfigSelectOption> = model_state
.available_models
.iter()
.map(|m| SessionConfigSelectOption::new(m.model_id.0.clone(), m.name.clone()))
.collect();
vec![
SessionConfigOption::select(
"mode",
"Mode",
mode_state.current_mode_id.0.clone(),
mode_options,
)
.category(SessionConfigOptionCategory::Mode),
SessionConfigOption::select(
"model",
"Model",
model_state.current_model_id.0.clone(),
model_options,
)
.category(SessionConfigOptionCategory::Model),
]
}
impl GooseAcpAgent {
pub fn permission_manager(&self) -> Arc<PermissionManager> {
Arc::clone(&self.permission_manager)
}
// TODO: goose reads Paths::in_state_dir globally (e.g. RequestLog), ignoring this data_dir.
pub async fn new(
provider_factory: ProviderConstructor,
builtins: Vec<String>,
@@ -373,7 +413,7 @@ impl GooseAcpAgent {
async fn create_agent_for_session(
&self,
cx: Option<&JrConnectionCx<AgentToClient>>,
cx: Option<&ConnectionTo<Client>>,
session_id: Option<&SessionId>,
goose_mode: Option<GooseMode>,
) -> Result<Arc<Agent>> {
@@ -506,7 +546,7 @@ impl GooseAcpAgent {
content_item: &MessageContent,
session_id: &SessionId,
session: &mut GooseAcpSession,
cx: &JrConnectionCx<AgentToClient>,
cx: &ConnectionTo<Client>,
) -> Result<(), sacp::Error> {
match content_item {
MessageContent::Text(text) => {
@@ -562,7 +602,7 @@ impl GooseAcpAgent {
tool_request: &goose::conversation::message::ToolRequest,
session_id: &SessionId,
session: &mut GooseAcpSession,
cx: &JrConnectionCx<AgentToClient>,
cx: &ConnectionTo<Client>,
) -> Result<(), sacp::Error> {
session
.tool_requests
@@ -592,7 +632,7 @@ impl GooseAcpAgent {
tool_response: &goose::conversation::message::ToolResponse,
session_id: &SessionId,
session: &mut GooseAcpSession,
cx: &JrConnectionCx<AgentToClient>,
cx: &ConnectionTo<Client>,
) -> Result<(), sacp::Error> {
let status = match &tool_response.tool_result {
Ok(result) if result.is_error == Some(true) => ToolCallStatus::Failed,
@@ -635,7 +675,7 @@ impl GooseAcpAgent {
#[allow(clippy::too_many_arguments)]
fn handle_tool_permission_request(
&self,
cx: &JrConnectionCx<AgentToClient>,
cx: &ConnectionTo<Client>,
agent: &Arc<Agent>,
session_id: &SessionId,
request_id: String,
@@ -776,7 +816,11 @@ impl GooseAcpAgent {
let capabilities = AgentCapabilities::new()
.load_session(true)
.session_capabilities(SessionCapabilities::new().list(SessionListCapabilities::new()))
.session_capabilities(
SessionCapabilities::new()
.list(SessionListCapabilities::new())
.close(SessionCloseCapabilities::new()),
)
.prompt_capabilities(
PromptCapabilities::new()
.image(true)
@@ -786,18 +830,15 @@ impl GooseAcpAgent {
.mcp_capabilities(McpCapabilities::new().http(true));
Ok(InitializeResponse::new(args.protocol_version)
.agent_capabilities(capabilities)
.auth_methods(vec![AuthMethod::new(
"goose-provider",
"Configure Provider",
)
.description(
"Run `goose configure` to set up your AI provider and API key",
.auth_methods(vec![AuthMethod::Agent(
AuthMethodAgent::new("goose-provider", "Configure Provider")
.description("Run `goose configure` to set up your AI provider and API key"),
)]))
}
async fn on_new_session(
&self,
cx: &JrConnectionCx<AgentToClient>,
cx: &ConnectionTo<Client>,
args: NewSessionRequest,
) -> Result<NewSessionResponse, sacp::Error> {
debug!(?args, "new session request");
@@ -849,13 +890,13 @@ impl GooseAcpAgent {
"Session started"
);
let model_state =
build_model_state(&*provider, &provider.get_model_config().model_name).await;
let model_state = build_model_state(&*provider).await?;
let mode_state = build_mode_state(self.goose_mode)?;
Ok(NewSessionResponse::new(SessionId::new(goose_session.id))
.models(model_state)
.modes(mode_state))
.models(model_state.clone())
.modes(mode_state.clone())
.config_options(build_config_options(&mode_state, &model_state)))
}
async fn init_provider(&self, agent: &Agent, session: &Session) -> Result<Arc<dyn Provider>> {
@@ -881,7 +922,8 @@ impl GooseAcpAgent {
) -> Result<Arc<Agent>, sacp::Error> {
let mut sessions = self.sessions.lock().await;
let session = sessions.get_mut(session_id).ok_or_else(|| {
sacp::Error::invalid_params().data(format!("Session not found: {}", session_id))
sacp::Error::resource_not_found(Some(session_id.to_string()))
.data(format!("Session not found: {}", session_id))
})?;
if let Some(token) = cancel_token {
session.cancel_token = Some(token);
@@ -912,7 +954,7 @@ impl GooseAcpAgent {
async fn on_load_session(
&self,
cx: &JrConnectionCx<AgentToClient>,
cx: &ConnectionTo<Client>,
args: LoadSessionRequest,
) -> Result<LoadSessionResponse, sacp::Error> {
debug!(?args, "load session request");
@@ -923,9 +965,9 @@ impl GooseAcpAgent {
.session_manager
.get_session(&session_id, true)
.await
.map_err(|e| {
sacp::Error::invalid_params()
.data(format!("Failed to load session {}: {}", session_id, e))
.map_err(|_| {
sacp::Error::resource_not_found(Some(session_id.clone()))
.data(format!("Session not found: {}", session_id))
})?;
let loaded_mode = goose_session.goose_mode;
@@ -1027,18 +1069,18 @@ impl GooseAcpAgent {
"Session loaded"
);
let model_state =
build_model_state(&*provider, &provider.get_model_config().model_name).await;
let model_state = build_model_state(&*provider).await?;
let mode_state = build_mode_state(goose_mode)?;
Ok(LoadSessionResponse::new()
.models(model_state)
.modes(mode_state))
.models(model_state.clone())
.modes(mode_state.clone())
.config_options(build_config_options(&mode_state, &model_state)))
}
async fn on_prompt(
&self,
cx: &JrConnectionCx<AgentToClient>,
cx: &ConnectionTo<Client>,
args: PromptRequest,
) -> Result<PromptResponse, sacp::Error> {
let session_id = args.session_id.0.to_string();
@@ -1162,6 +1204,25 @@ impl GooseAcpAgent {
Ok(SetSessionModelResponse::new())
}
async fn build_config_update(
&self,
session_id: &SessionId,
) -> Result<(SessionNotification, Vec<SessionConfigOption>), sacp::Error> {
let agent = self.get_session_agent(&session_id.0, None).await?;
let provider = agent.provider().await.map_err(|e| {
sacp::Error::internal_error().data(format!("Failed to get provider: {}", e))
})?;
let goose_mode = agent.goose_mode().await;
let model_state = build_model_state(&*provider).await?;
let mode_state = build_mode_state(goose_mode)?;
let config_options = build_config_options(&mode_state, &model_state);
let notification = SessionNotification::new(
session_id.clone(),
SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(config_options.clone())),
);
Ok((notification, config_options))
}
async fn on_set_mode(
&self,
session_id: &str,
@@ -1181,11 +1242,44 @@ impl GooseAcpAgent {
Ok(SetSessionModeResponse::new())
}
async fn on_list_sessions(&self) -> Result<ListSessionsResponse, sacp::Error> {
let sessions = self
.session_manager
.list_sessions()
.await
.map_err(|e| sacp::Error::internal_error().data(e.to_string()))?;
let session_infos: Vec<SessionInfo> = sessions
.into_iter()
.map(|s| {
SessionInfo::new(SessionId::new(s.id), s.working_dir)
.title(s.name)
.updated_at(s.updated_at.to_rfc3339())
})
.collect();
Ok(ListSessionsResponse::new(session_infos))
}
async fn on_close_session(
&self,
session_id: &str,
) -> Result<CloseSessionResponse, sacp::Error> {
let mut sessions = self.sessions.lock().await;
// Cancel before removing so on_prompt sees cancellation before session disappears.
if let Some(session) = sessions.get(session_id) {
if let Some(ref token) = session.cancel_token {
token.cancel();
}
}
sessions.remove(session_id);
info!(session_id = %session_id, "session closed");
Ok(CloseSessionResponse::new())
}
}
#[custom_methods]
impl GooseAcpAgent {
#[custom_method("extensions/add")]
#[custom_method("_goose/extensions/add")]
async fn on_add_extension(
&self,
req: AddExtensionRequest,
@@ -1200,7 +1294,7 @@ impl GooseAcpAgent {
Ok(EmptyResponse {})
}
#[custom_method("extensions/remove")]
#[custom_method("_goose/extensions/remove")]
async fn on_remove_extension(
&self,
req: RemoveExtensionRequest,
@@ -1213,7 +1307,7 @@ impl GooseAcpAgent {
Ok(EmptyResponse {})
}
#[custom_method("tools")]
#[custom_method("_goose/tools")]
async fn on_get_tools(&self, req: GetToolsRequest) -> Result<GetToolsResponse, sacp::Error> {
let agent = self.get_session_agent(&req.session_id, None).await?;
let tools = agent.list_tools(&req.session_id, None).await;
@@ -1225,7 +1319,7 @@ impl GooseAcpAgent {
Ok(GetToolsResponse { tools: tools_json })
}
#[custom_method("resource/read")]
#[custom_method("_goose/resource/read")]
async fn on_read_resource(
&self,
req: ReadResourceRequest,
@@ -1244,7 +1338,7 @@ impl GooseAcpAgent {
})
}
#[custom_method("working_dir/update")]
#[custom_method("_goose/working_dir/update")]
async fn on_update_working_dir(
&self,
req: UpdateWorkingDirRequest,
@@ -1276,24 +1370,7 @@ impl GooseAcpAgent {
Ok(EmptyResponse {})
}
#[custom_method("session/list")]
async fn on_list_sessions(&self) -> Result<ListSessionsResponse, sacp::Error> {
let sessions = self
.session_manager
.list_sessions()
.await
.map_err(|e| sacp::Error::internal_error().data(e.to_string()))?;
let session_infos: Vec<SessionInfo> = sessions
.into_iter()
.map(|s| {
SessionInfo::new(SessionId::new(s.id), s.working_dir)
.title(s.name)
.updated_at(s.updated_at.to_rfc3339())
})
.collect();
Ok(ListSessionsResponse::new(session_infos))
}
// TODO: use typed GetSessionRequest when agent-client-protocol-schema adds it (Discussion #60)
#[custom_method("session/get")]
async fn on_get_session(
&self,
@@ -1311,6 +1388,7 @@ impl GooseAcpAgent {
})
}
// TODO: use typed DeleteSessionRequest when agent-client-protocol-schema adds it (RFD #395)
#[custom_method("session/delete")]
async fn on_delete_session(
&self,
@@ -1320,10 +1398,11 @@ impl GooseAcpAgent {
.delete_session(&req.session_id)
.await
.map_err(|e| sacp::Error::internal_error().data(e.to_string()))?;
self.sessions.lock().await.remove(&req.session_id);
Ok(EmptyResponse {})
}
#[custom_method("session/export")]
#[custom_method("_goose/session/export")]
async fn on_export_session(
&self,
req: ExportSessionRequest,
@@ -1336,7 +1415,7 @@ impl GooseAcpAgent {
Ok(ExportSessionResponse { data })
}
#[custom_method("session/import")]
#[custom_method("_goose/session/import")]
async fn on_import_session(
&self,
req: ImportSessionRequest,
@@ -1353,7 +1432,7 @@ impl GooseAcpAgent {
})
}
#[custom_method("config/extensions")]
#[custom_method("_goose/config/extensions")]
async fn on_get_extensions(&self) -> Result<GetExtensionsResponse, sacp::Error> {
let extensions = goose::config::extensions::get_all_extensions();
let warnings = goose::config::extensions::get_warnings();
@@ -1373,62 +1452,57 @@ pub struct GooseAcpHandler {
pub agent: Arc<GooseAcpAgent>,
}
impl JrMessageHandler for GooseAcpHandler {
type Link = AgentToClient;
impl HandleDispatchFrom<Client> for GooseAcpHandler {
fn describe_chain(&self) -> impl std::fmt::Debug {
"goose-acp"
}
fn handle_message(
fn handle_dispatch_from(
&mut self,
message: MessageCx,
cx: JrConnectionCx<AgentToClient>,
) -> impl std::future::Future<Output = Result<Handled<MessageCx>, sacp::Error>> + Send {
use sacp::util::MatchMessageFrom;
use sacp::JrRequestCx;
message: Dispatch,
cx: ConnectionTo<Client>,
) -> impl std::future::Future<Output = Result<Handled<Dispatch>, sacp::Error>> + Send {
let agent = self.agent.clone();
// The MatchMessageFrom chain produces an ~85KB async state machine.
// The MatchDispatchFrom chain produces an ~85KB async state machine.
// Box::pin moves it to the heap so it doesn't overflow the tokio worker stack.
Box::pin(async move {
MatchMessageFrom::new(message, &cx)
MatchDispatchFrom::new(message, &cx)
.if_request(
|req: InitializeRequest, req_cx: JrRequestCx<InitializeResponse>| async {
req_cx.respond(agent.on_initialize(req).await?)
|req: InitializeRequest, responder: Responder<InitializeResponse>| async {
responder.respond_with_result(agent.on_initialize(req).await)
},
)
.await
.if_request(
|_req: AuthenticateRequest, req_cx: JrRequestCx<AuthenticateResponse>| async {
req_cx.respond(AuthenticateResponse::new())
|_req: AuthenticateRequest, responder: Responder<AuthenticateResponse>| async {
responder.respond(AuthenticateResponse::new())
},
)
.await
.if_request(
|req: NewSessionRequest, req_cx: JrRequestCx<NewSessionResponse>| async {
req_cx.respond(agent.on_new_session(&cx, req).await?)
|req: NewSessionRequest, responder: Responder<NewSessionResponse>| async {
responder.respond_with_result(agent.on_new_session(&cx, req).await)
},
)
.await
.if_request(
|req: LoadSessionRequest, req_cx: JrRequestCx<LoadSessionResponse>| async {
req_cx.respond(agent.on_load_session(&cx, req).await?)
|req: LoadSessionRequest, responder: Responder<LoadSessionResponse>| async {
responder.respond_with_result(agent.on_load_session(&cx, req).await)
},
)
.await
.if_request(
|req: PromptRequest, req_cx: JrRequestCx<PromptResponse>| async {
|req: PromptRequest, responder: Responder<PromptResponse>| async {
let agent = agent.clone();
let cx_clone = cx.clone();
cx.spawn(async move {
match agent.on_prompt(&cx_clone, req).await {
Ok(response) => {
req_cx.respond(response)?;
responder.respond(response)?;
}
Err(e) => {
req_cx.respond_with_error(e)?;
responder.respond_with_error(e)?;
}
}
Ok(())
@@ -1439,76 +1513,118 @@ impl JrMessageHandler for GooseAcpHandler {
.await
.if_notification(|notif: CancelNotification| async { agent.on_cancel(notif).await })
.await
// Handle methods not yet in the sacp typed API.
// - session/set_model, session/set_mode: typed support pending in sacp
// - _<method>: custom requests that will eventually route to goose-server
.otherwise({
// set_config_option (SACP 11) and legacy set_mode/set_model; custom _goose/* in otherwise.
.if_request({
let agent = agent.clone();
let cx = cx.clone();
|message: MessageCx| async move {
|req: SetSessionConfigOptionRequest, responder: Responder<SetSessionConfigOptionResponse>| async move {
let value_id = req.value.as_value_id()
.ok_or_else(|| sacp::Error::invalid_params().data("Expected a value ID"))?
.clone();
let session_id = req.session_id.clone();
match req.config_id.0.as_ref() {
"mode" => {
match agent.on_set_mode(&session_id.0, &value_id.0).await {
Ok(_) => {}
Err(e) => { responder.respond_with_error(e)?; return Ok(()); }
}
}
"model" => {
match agent.on_set_model(&session_id.0, &value_id.0).await {
Ok(_) => {}
Err(e) => { responder.respond_with_error(e)?; return Ok(()); }
}
}
other => {
responder.respond_with_error(
sacp::Error::invalid_params().data(format!("Unsupported config option: {}", other))
)?;
return Ok(());
}
}
let (notification, config_options) = agent.build_config_update(&session_id).await?;
cx.send_notification(notification)?;
responder.respond(SetSessionConfigOptionResponse::new(config_options))?;
Ok(())
}
})
.await
.if_request({
let agent = agent.clone();
let cx = cx.clone();
|req: SetSessionModeRequest, responder: Responder<SetSessionModeResponse>| async move {
let session_id = req.session_id.clone();
let mode_id = req.mode_id.clone();
match agent.on_set_mode(&session_id.0, &mode_id.0).await {
Ok(resp) => {
// Notify before responding so clients see the mode update before block_task unblocks.
cx.send_notification(SessionNotification::new(
session_id,
SessionUpdate::CurrentModeUpdate(
CurrentModeUpdate::new(mode_id),
),
))?;
responder.respond(resp)?;
}
Err(e) => {
responder.respond_with_error(e)?;
}
}
Ok(())
}
})
.await
.if_request({
let agent = agent.clone();
let cx = cx.clone();
|req: SetSessionModelRequest, responder: Responder<SetSessionModelResponse>| async move {
let session_id = req.session_id.clone();
match agent.on_set_model(&session_id.0, &req.model_id.0).await {
Ok(resp) => {
let (notification, _) = agent.build_config_update(&session_id).await?;
cx.send_notification(notification)?;
responder.respond(resp)?;
}
Err(e) => responder.respond_with_error(e)?,
}
Ok(())
}
})
.await
.if_request({
let agent = agent.clone();
|_req: ListSessionsRequest, responder: Responder<ListSessionsResponse>| async move {
responder.respond(agent.on_list_sessions().await?)
}
})
.await
.if_request({
let agent = agent.clone();
|req: CloseSessionRequest, responder: Responder<CloseSessionResponse>| async move {
responder.respond(agent.on_close_session(&req.session_id.0).await?)
}
})
.await
.otherwise({
let agent = agent.clone();
|message: Dispatch| async move {
match message {
MessageCx::Request(req, request_cx)
if req.method == "session/set_mode" =>
{
let params: SetSessionModeRequest =
serde_json::from_value(req.params).map_err(|e| {
sacp::Error::invalid_params().data(e.to_string())
})?;
let session_id = params.session_id.clone();
let mode_id = params.mode_id.clone();
match agent.on_set_mode(&session_id.0, &mode_id.0).await {
Ok(resp) => {
let json = serde_json::to_value(resp).map_err(|e| {
sacp::Error::internal_error().data(e.to_string())
})?;
// Notify before responding so clients see the mode
// update before block_task unblocks (serial dispatch).
cx.send_notification(SessionNotification::new(
session_id,
SessionUpdate::CurrentModeUpdate(
CurrentModeUpdate::new(mode_id),
),
))?;
request_cx.respond(json)?;
}
Err(e) => {
request_cx.respond_with_error(e)?;
}
}
Ok(())
}
MessageCx::Request(req, request_cx)
if req.method == "session/set_model" =>
{
let params: SetSessionModelRequest =
serde_json::from_value(req.params).map_err(|e| {
sacp::Error::invalid_params().data(e.to_string())
})?;
let resp = agent
.on_set_model(&params.session_id.0, &params.model_id.0)
.await?;
let json = serde_json::to_value(resp).map_err(|e| {
sacp::Error::internal_error().data(e.to_string())
})?;
request_cx.respond(json)?;
Ok(())
}
MessageCx::Request(req, request_cx) if req.method == "session/list" => {
let resp = agent.on_list_sessions().await?;
let json = serde_json::to_value(resp).map_err(|e| {
sacp::Error::internal_error().data(e.to_string())
})?;
request_cx.respond(json)?;
Ok(())
}
MessageCx::Request(req, request_cx) if req.method.starts_with('_') => {
Dispatch::Request(req, responder) => {
match agent.handle_custom_request(&req.method, req.params).await {
Ok(json) => request_cx.respond(json)?,
Err(e) => request_cx.respond_with_error(e)?,
Ok(json) => responder.respond(json)?,
Err(e) => responder.respond_with_error(e)?,
}
Ok(())
}
_ => Err(sacp::Error::method_not_found()),
Dispatch::Response(result, router) => {
debug!(method = %router.method(), id = %router.id(), ok = result.is_ok(), "routing response");
router.respond_with_result(result)?;
Ok(())
}
Dispatch::Notification(notif) => {
debug!(method = %notif.method, "unhandled notification");
Ok(())
}
}
}
})
@@ -1530,10 +1646,11 @@ where
Box::pin(async move {
let handler = GooseAcpHandler { agent };
AgentToClient::builder()
SacpAgent
.builder()
.name("goose-acp")
.with_handler(handler)
.serve(ByteStreams::new(write, read))
.connect_to(ByteStreams::new(write, read))
.await?;
Ok(())
@@ -1565,8 +1682,8 @@ mod tests {
use rmcp::model::{CallToolRequestParams, Content as RmcpContent};
use sacp::schema::{
EnvVariable, HttpHeader, McpServer, McpServerHttp, McpServerSse, McpServerStdio,
PermissionOptionId, ResourceLink, SelectedPermissionOutcome, SessionMode, SessionModeId,
SessionModeState,
PermissionOptionId, ResourceLink, SelectedPermissionOutcome, SessionConfigSelectOption,
SessionMode, SessionModeId, SessionModeState,
};
use std::io::Write;
use std::path::PathBuf;
@@ -1720,7 +1837,7 @@ print(\"hello, world\")
}
#[async_trait::async_trait]
impl goose::providers::base::Provider for MockModelProvider {
impl Provider for MockModelProvider {
fn get_name(&self) -> &str {
"mock"
}
@@ -1746,40 +1863,30 @@ print(\"hello, world\")
}
#[test_case(
"model-a", Ok(vec!["model-a".into(), "model-b".into()])
=> SessionModelState::new(
ModelId::new("model-a"),
Ok(vec!["model-a".into(), "model-b".into()])
=> Ok(SessionModelState::new(
ModelId::new("unused"),
vec![ModelInfo::new(ModelId::new("model-a"), "model-a"),
ModelInfo::new(ModelId::new("model-b"), "model-b")],
)
))
; "returns current and available models"
)]
#[test_case(
"model-a", Ok(vec![])
=> SessionModelState::new(ModelId::new("model-a"), vec![])
Ok(vec![])
=> Ok(SessionModelState::new(ModelId::new("unused"), vec![]))
; "empty model list"
)]
#[test_case(
"model-a", Err(ProviderError::ExecutionError("fail".into()))
=> SessionModelState::new(ModelId::new("model-a"), vec![])
; "fetch error falls back to current model only"
)]
#[test_case(
"switched-model", Ok(vec!["model-a".into(), "switched-model".into()])
=> SessionModelState::new(
ModelId::new("switched-model"),
vec![ModelInfo::new(ModelId::new("model-a"), "model-a"),
ModelInfo::new(ModelId::new("switched-model"), "switched-model")],
)
; "current model reflects switched model"
Err(ProviderError::ExecutionError("fail".into()))
=> Err(sacp::Error::internal_error().data("Execution error: fail".to_string()))
; "fetch error propagates"
)]
#[tokio::test]
async fn test_build_model_state(
current_model: &str,
models: Result<Vec<String>, ProviderError>,
) -> SessionModelState {
) -> Result<SessionModelState, sacp::Error> {
let provider = MockModelProvider { models };
build_model_state(&provider, current_model).await
build_model_state(&provider).await
}
fn json_object(pairs: Vec<(&str, serde_json::Value)>) -> rmcp::model::JsonObject {
@@ -1920,24 +2027,94 @@ print(\"hello, world\")
.map(|locs| locs.into_iter().map(|loc| (loc.path, loc.line)).collect())
}
#[test]
fn test_build_mode_state() {
let state = build_mode_state(GooseMode::Auto).unwrap();
assert_eq!(
state,
SessionModeState::new(
SessionModeId::new("auto"),
#[test_case(
GooseMode::Auto
=> Ok(SessionModeState::new(
SessionModeId::new("auto"),
vec![
SessionMode::new(SessionModeId::new("auto"), "auto")
.description("Automatically approve tool calls"),
SessionMode::new(SessionModeId::new("approve"), "approve")
.description("Ask before every tool call"),
SessionMode::new(SessionModeId::new("smart_approve"), "smart_approve")
.description("Ask only for sensitive tool calls"),
SessionMode::new(SessionModeId::new("chat"), "chat")
.description("Chat only, no tool calls"),
],
))
; "auto mode"
)]
#[test_case(
GooseMode::Approve
=> Ok(SessionModeState::new(
SessionModeId::new("approve"),
vec![
SessionMode::new(SessionModeId::new("auto"), "auto")
.description("Automatically approve tool calls"),
SessionMode::new(SessionModeId::new("approve"), "approve")
.description("Ask before every tool call"),
SessionMode::new(SessionModeId::new("smart_approve"), "smart_approve")
.description("Ask only for sensitive tool calls"),
SessionMode::new(SessionModeId::new("chat"), "chat")
.description("Chat only, no tool calls"),
],
))
; "approve mode"
)]
fn test_build_mode_state(current_mode: GooseMode) -> Result<SessionModeState, sacp::Error> {
build_mode_state(current_mode)
}
#[test_case(
build_mode_state(GooseMode::Auto).unwrap(),
SessionModelState::new(
ModelId::new("gpt-4"),
vec![ModelInfo::new(ModelId::new("gpt-4"), "gpt-4"), ModelInfo::new(ModelId::new("gpt-3.5"), "gpt-3.5")],
)
=> vec![
SessionConfigOption::select(
"mode", "Mode", "auto",
vec![
SessionMode::new(SessionModeId::new("auto"), "auto")
.description("Automatically approve tool calls"),
SessionMode::new(SessionModeId::new("approve"), "approve")
.description("Ask before every tool call"),
SessionMode::new(SessionModeId::new("smart_approve"), "smart_approve")
.description("Ask only for sensitive tool calls"),
SessionMode::new(SessionModeId::new("chat"), "chat")
.description("Chat only, no tool calls"),
SessionConfigSelectOption::new("auto", "auto").description("Automatically approve tool calls"),
SessionConfigSelectOption::new("approve", "approve").description("Ask before every tool call"),
SessionConfigSelectOption::new("smart_approve", "smart_approve").description("Ask only for sensitive tool calls"),
SessionConfigSelectOption::new("chat", "chat").description("Chat only, no tool calls"),
],
)
);
).category(SessionConfigOptionCategory::Mode),
SessionConfigOption::select(
"model", "Model", "gpt-4",
vec![
SessionConfigSelectOption::new("gpt-4", "gpt-4"),
SessionConfigSelectOption::new("gpt-3.5", "gpt-3.5"),
],
).category(SessionConfigOptionCategory::Model),
]
; "auto mode with multiple models"
)]
#[test_case(
build_mode_state(GooseMode::Approve).unwrap(),
SessionModelState::new(ModelId::new("only-model"), vec![ModelInfo::new(ModelId::new("only-model"), "only-model")])
=> vec![
SessionConfigOption::select(
"mode", "Mode", "approve",
vec![
SessionConfigSelectOption::new("auto", "auto").description("Automatically approve tool calls"),
SessionConfigSelectOption::new("approve", "approve").description("Ask before every tool call"),
SessionConfigSelectOption::new("smart_approve", "smart_approve").description("Ask only for sensitive tool calls"),
SessionConfigSelectOption::new("chat", "chat").description("Chat only, no tool calls"),
],
).category(SessionConfigOptionCategory::Mode),
SessionConfigOption::select(
"model", "Model", "only-model",
vec![SessionConfigSelectOption::new("only-model", "only-model")],
).category(SessionConfigOptionCategory::Model),
]
; "approve mode with single model"
)]
fn test_build_config_options(
mode_state: SessionModeState,
model_state: SessionModelState,
) -> Vec<SessionConfigOption> {
build_config_options(&mode_state, &model_state)
}
}
+399 -67
View File
@@ -6,18 +6,125 @@
pub mod fixtures;
use fixtures::{
assert_notifications, Connection, FsFixture, Notification, OpenAiFixture, PermissionDecision,
Session, SessionResult, TerminalCall, TerminalFixture, TestConnectionConfig,
Session, SessionData, TerminalCall, TerminalFixture, TestConnectionConfig,
};
use fs_err as fs;
use goose::config::base::CONFIG_YAML_NAME;
use goose::config::GooseMode;
use goose::providers::provider_registry::ProviderConstructor;
use goose_test_support::{McpFixture, FAKE_CODE, TEST_IMAGE_B64, TEST_MODEL};
use sacp::schema::{McpServer, McpServerHttp, ModelId, SessionModeId, ToolCallStatus, ToolKind};
use sacp::schema::{
ListSessionsResponse, McpServer, McpServerHttp, ModelId, SessionInfo, SessionModeId,
ToolCallStatus, ToolKind,
};
use sqlx::sqlite::SqlitePoolOptions;
use std::sync::Arc;
const SHELL_TEST_CONTENT: &str = "test-shell-content-98765";
struct BasicSession<C: Connection> {
conn: C,
session: C::Session,
}
async fn new_basic_session<C: Connection>(config: TestConnectionConfig) -> BasicSession<C> {
let expected_session_id = C::expected_session_id();
let openai = OpenAiFixture::new(
vec![(
r#"</info-msg>\nwhat is 1+1""#.into(),
include_str!("../test_data/openai_basic.txt"),
)],
expected_session_id.clone(),
)
.await;
let mut conn = C::new(config, openai).await;
let SessionData { mut session, .. } = conn.new_session().await.unwrap();
expected_session_id.set(&session.session_id().0);
let output = session
.prompt("what is 1+1", PermissionDecision::Cancel)
.await
.unwrap();
assert_eq!(output.text, "2");
BasicSession { conn, session }
}
pub async fn run_list_sessions<C: Connection>() {
let BasicSession { conn, session } =
new_basic_session::<C>(TestConnectionConfig::default()).await;
let mut response = conn.list_sessions().await.unwrap();
for s in &mut response.sessions {
s.updated_at = None;
}
assert_eq!(
response,
ListSessionsResponse::new(vec![SessionInfo::new(
session.session_id().clone(),
session.work_dir()
)
.title("ACP Session".to_string())])
);
}
pub async fn run_close_session<C: Connection>() {
let BasicSession { conn, session } =
new_basic_session::<C>(TestConnectionConfig::default()).await;
let sid = &session.session_id().0;
let data_root = conn.data_root();
conn.close_session(sid).await.unwrap();
// Provider close drops the connection, so verify via DB not list_sessions.
let db_path = data_root.join("sessions").join("sessions.db");
let pool = SqlitePoolOptions::new()
.connect(&format!("sqlite:{}?mode=ro", db_path.display()))
.await
.unwrap();
let db_ids: Vec<String> = sqlx::query_scalar("SELECT id FROM sessions")
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(db_ids.len(), 1);
let expected_session_id = C::expected_session_id();
expected_session_id.set(sid);
expected_session_id.assert_matches(&db_ids[0]);
}
pub async fn run_delete_session<C: Connection>() {
let BasicSession { mut conn, session } =
new_basic_session::<C>(TestConnectionConfig::default()).await;
let sid = session.session_id().0.to_string();
let before: Vec<_> = conn
.list_sessions()
.await
.unwrap()
.sessions
.iter()
.map(|s| s.session_id.clone())
.collect();
assert!(before.contains(session.session_id()));
conn.delete_session(&sid).await.unwrap();
let after: Vec<_> = conn
.list_sessions()
.await
.unwrap()
.sessions
.iter()
.map(|s| s.session_id.clone())
.collect();
assert!(!after.contains(session.session_id()));
let err = conn.load_session(&sid, vec![]).await.unwrap_err();
let sacp_err = err.downcast::<sacp::Error>().unwrap();
assert_eq!(sacp_err.code, sacp::ErrorCode::ResourceNotFound);
}
pub async fn run_config_mcp<C: Connection>() {
let temp_dir = tempfile::tempdir().unwrap();
let expected_session_id = C::expected_session_id();
@@ -51,10 +158,13 @@ pub async fn run_config_mcp<C: Connection>() {
};
let mut conn = C::new(config, openai).await;
let SessionResult { mut session, .. } = conn.new_session().await;
let SessionData { mut session, .. } = conn.new_session().await.unwrap();
expected_session_id.set(&session.session_id().0);
let output = session.prompt(prompt, PermissionDecision::Cancel).await;
let output = session
.prompt(prompt, PermissionDecision::Cancel)
.await
.unwrap();
assert_eq!(output.text, FAKE_CODE);
assert_notifications(
&session.notifications(),
@@ -100,10 +210,13 @@ pub async fn run_fs_read_text_file_true<C: Connection>() {
..Default::default()
};
let mut conn = C::new(config, openai).await;
let SessionResult { mut session, .. } = conn.new_session().await;
let SessionData { mut session, .. } = conn.new_session().await.unwrap();
expected_session_id.set(&session.session_id().0);
let output = session.prompt(prompt, PermissionDecision::Cancel).await;
let output = session
.prompt(prompt, PermissionDecision::Cancel)
.await
.unwrap();
assert_eq!(output.text, "test-read-content-12345");
assert_notifications(
&session.notifications(),
@@ -144,10 +257,13 @@ pub async fn run_fs_write_text_file_false<C: Connection>() {
..Default::default()
};
let mut conn = C::new(config, openai).await;
let SessionResult { mut session, .. } = conn.new_session().await;
let SessionData { mut session, .. } = conn.new_session().await.unwrap();
expected_session_id.set(&session.session_id().0);
let output = session.prompt(prompt, PermissionDecision::AllowOnce).await;
let output = session
.prompt(prompt, PermissionDecision::AllowOnce)
.await
.unwrap();
assert!(!output.text.is_empty());
assert_eq!(
fs::read_to_string("/tmp/test_acp_write.txt").unwrap(),
@@ -193,10 +309,13 @@ pub async fn run_fs_write_text_file_true<C: Connection>() {
..Default::default()
};
let mut conn = C::new(config, openai).await;
let SessionResult { mut session, .. } = conn.new_session().await;
let SessionData { mut session, .. } = conn.new_session().await.unwrap();
expected_session_id.set(&session.session_id().0);
let output = session.prompt(prompt, PermissionDecision::AllowOnce).await;
let output = session
.prompt(prompt, PermissionDecision::AllowOnce)
.await
.unwrap();
assert!(!output.text.is_empty());
assert_notifications(
&session.notifications(),
@@ -227,7 +346,7 @@ pub async fn run_initialize_doesnt_hit_provider<C: Connection>() {
assert!(conn
.auth_methods()
.iter()
.any(|m| &*m.id.0 == "goose-provider"));
.any(|m| m.id().0.as_ref() == "goose-provider"));
}
pub async fn run_load_mode<C: Connection>() {
@@ -263,7 +382,7 @@ pub async fn run_load_mode<C: Connection>() {
};
let mut conn = C::new(config, openai).await;
let SessionResult { session, modes, .. } = conn.new_session().await;
let SessionData { session, modes, .. } = conn.new_session().await.unwrap();
assert_eq!(
modes.unwrap().current_mode_id,
SessionModeId::new(<&str>::from(GooseMode::default()))
@@ -273,11 +392,11 @@ pub async fn run_load_mode<C: Connection>() {
.await
.unwrap();
let SessionResult {
let SessionData {
session: mut loaded,
modes,
..
} = conn.load_session(&session_id, vec![]).await;
} = conn.load_session(&session_id, vec![]).await.unwrap();
assert_eq!(
modes.unwrap().current_mode_id,
SessionModeId::new(<&str>::from(GooseMode::Approve))
@@ -285,7 +404,10 @@ pub async fn run_load_mode<C: Connection>() {
// Approve mode + Cancel = permission denied → tool fails
expected_session_id.set(&loaded.session_id().0);
let output = loaded.prompt(prompt, PermissionDecision::Cancel).await;
let output = loaded
.prompt(prompt, PermissionDecision::Cancel)
.await
.unwrap();
assert_eq!(output.tool_status.unwrap(), ToolCallStatus::Failed);
assert_notifications(
&loaded.notifications(),
@@ -310,7 +432,7 @@ pub async fn run_load_model<C: Connection>() {
.await;
let mut conn = C::new(TestConnectionConfig::default(), openai).await;
let SessionResult { mut session, .. } = conn.new_session().await;
let SessionData { mut session, .. } = conn.new_session().await.unwrap();
expected_session_id.set(&session.session_id().0);
let session_id = session.session_id().0.to_string();
@@ -318,10 +440,11 @@ pub async fn run_load_model<C: Connection>() {
let output = session
.prompt("what is 1+1", PermissionDecision::Cancel)
.await;
.await
.unwrap();
assert_eq!(output.text, "2");
let SessionResult { models, .. } = conn.load_session(&session_id, vec![]).await;
let SessionData { models, .. } = conn.load_session(&session_id, vec![]).await.unwrap();
assert_eq!(&*models.unwrap().current_model_id.0, "o4-mini");
}
@@ -362,28 +485,107 @@ pub async fn run_load_session_mcp<C: Connection>() {
..Default::default()
};
let mut conn = C::new(config, openai).await;
let SessionResult { mut session, .. } = conn.new_session().await;
let SessionData { mut session, .. } = conn.new_session().await.unwrap();
expected_session_id.set(&session.session_id().0);
// First prompt: tool should work in the new session.
let output = session.prompt(prompt, PermissionDecision::Cancel).await;
let output = session
.prompt(prompt, PermissionDecision::Cancel)
.await
.unwrap();
assert_eq!(output.text, FAKE_CODE, "tool call failed in new session");
// Load the same session with MCP servers re-specified.
let session_id = session.session_id().0.to_string();
let SessionResult {
let SessionData {
session: mut loaded_session,
..
} = conn.load_session(&session_id, mcp_servers).await;
} = conn.load_session(&session_id, mcp_servers).await.unwrap();
// Second prompt: tool should work in the loaded session.
let output = loaded_session
.prompt(prompt, PermissionDecision::Cancel)
.await;
.await
.unwrap();
assert_eq!(output.text, FAKE_CODE, "tool call failed in loaded session");
}
pub async fn run_load_session_error<C: Connection>() {
let openai = OpenAiFixture::new(vec![], C::expected_session_id()).await;
let mut conn = C::new(TestConnectionConfig::default(), openai).await;
let err = conn
.load_session("nonexistent-session-id", vec![])
.await
.unwrap_err();
let sacp_err = err.downcast::<sacp::Error>().unwrap();
assert_eq!(
sacp_err,
sacp::Error::resource_not_found(Some("nonexistent-session-id".to_string()))
.data("Session not found: nonexistent-session-id")
);
}
pub async fn run_config_option_mode_set<C: Connection>() {
run_mode_set_impl::<C>(SetModeVia::ConfigOption).await;
}
pub async fn run_config_option_set_error<C: Connection>(
config_id: &str,
value: &str,
session_id_override: Option<&str>,
expected: sacp::Error,
) {
let openai = OpenAiFixture::new(vec![], C::expected_session_id()).await;
let mut conn = C::new(TestConnectionConfig::default(), openai).await;
let SessionData { session, .. } = conn.new_session().await.unwrap();
let target_session_id = session_id_override
.map(str::to_string)
.unwrap_or_else(|| session.session_id().0.to_string());
let err = conn
.set_config_option(&target_session_id, config_id, value)
.await
.unwrap_err();
let sacp_err = err.downcast::<sacp::Error>().unwrap();
assert_eq!(sacp_err, expected);
}
#[macro_export]
macro_rules! tests_config_option_set_error {
($conn:ty) => {
#[test_case::test_case("mode", "not_a_mode", None, sacp::Error::invalid_params().data("Invalid mode: not_a_mode") ; "invalid mode via config option")]
#[test_case::test_case("mode", "auto", Some("nonexistent-session-id"), sacp::Error::resource_not_found(Some("nonexistent-session-id".to_string())).data("Session not found: nonexistent-session-id") ; "session not found via config option")]
#[test_case::test_case("thought_level", "high", None, sacp::Error::invalid_params().data("Unsupported config option: thought_level") ; "unsupported config option")]
fn test_config_option_set_error(
config_id: &'static str,
value: &'static str,
session_id: Option<&'static str>,
expected: sacp::Error,
) {
common_tests::fixtures::run_test(async move {
common_tests::run_config_option_set_error::<$conn>(
config_id, value, session_id, expected,
)
.await
});
}
};
}
pub async fn run_mode_set<C: Connection>() {
run_mode_set_impl::<C>(SetModeVia::Dedicated).await;
}
enum SetModeVia {
Dedicated,
ConfigOption,
}
async fn run_mode_set_impl<C: Connection>(via: SetModeVia) {
let temp_dir = tempfile::tempdir().unwrap();
let expected_session_id = C::expected_session_id();
let prompt = "Use the get_code tool and output only its result.";
@@ -412,26 +614,45 @@ pub async fn run_mode_set<C: Connection>() {
let config = TestConnectionConfig {
data_root: temp_dir.path().to_path_buf(),
strip_config_options: matches!(via, SetModeVia::Dedicated),
..Default::default()
};
let mut conn = C::new(config, openai).await;
let SessionResult {
let SessionData {
session: mut session_a,
..
} = conn.new_session().await;
} = conn.new_session().await.unwrap();
let SessionResult {
let SessionData {
session: mut session_b,
..
} = conn.new_session().await;
conn.set_mode(&session_b.session_id().0, <&str>::from(GooseMode::Approve))
} = conn.new_session().await.unwrap();
let session_id = &session_b.session_id().0;
let approve = <&str>::from(GooseMode::Approve);
match via {
SetModeVia::Dedicated => conn.set_mode(session_id, approve).await.unwrap(),
SetModeVia::ConfigOption => conn
.set_config_option(session_id, "mode", approve)
.await
.unwrap(),
}
match via {
SetModeVia::Dedicated => {
assert_notifications(&session_b.notifications(), &[Notification::CurrentMode])
}
SetModeVia::ConfigOption => {
assert_notifications(&session_b.notifications(), &[Notification::ConfigOption])
}
}
// Approve mode + Cancel = permission denied -> tool fails
expected_session_id.set(&session_b.session_id().0);
let output = session_b
.prompt(prompt, PermissionDecision::Cancel)
.await
.unwrap();
// Approve mode + Cancel = permission denied → tool fails
expected_session_id.set(&session_b.session_id().0);
let output = session_b.prompt(prompt, PermissionDecision::Cancel).await;
assert_eq!(output.tool_status.unwrap(), ToolCallStatus::Failed);
assert_notifications(
&session_b.notifications(),
@@ -443,10 +664,13 @@ pub async fn run_mode_set<C: Connection>() {
],
);
// Auto mode ignores Cancel tool succeeds without permission prompt
// Auto mode ignores Cancel -- tool succeeds without permission prompt
conn.reset_openai();
expected_session_id.set(&session_a.session_id().0);
let output = session_a.prompt(prompt, PermissionDecision::Cancel).await;
let output = session_a
.prompt(prompt, PermissionDecision::Cancel)
.await
.unwrap();
assert_eq!(output.text, FAKE_CODE);
assert_notifications(
&session_a.notifications(),
@@ -466,7 +690,7 @@ pub async fn run_mode_set_error<C: Connection>(
) {
let openai = OpenAiFixture::new(vec![], C::expected_session_id()).await;
let mut conn = C::new(TestConnectionConfig::default(), openai).await;
let SessionResult { session, .. } = conn.new_session().await;
let SessionData { session, .. } = conn.new_session().await.unwrap();
let target_session_id = session_id_override
.map(str::to_string)
@@ -485,7 +709,7 @@ pub async fn run_mode_set_error<C: Connection>(
macro_rules! tests_mode_set_error {
($conn:ty) => {
#[test_case::test_case("not_a_mode", None, sacp::Error::invalid_params().data("Invalid mode: not_a_mode") ; "invalid mode")]
#[test_case::test_case("auto", Some("nonexistent-session-id"), sacp::Error::invalid_params().data("Session not found: nonexistent-session-id") ; "session not found")]
#[test_case::test_case("auto", Some("nonexistent-session-id"), sacp::Error::resource_not_found(Some("nonexistent-session-id".to_string())).data("Session not found: nonexistent-session-id") ; "session not found")]
fn test_mode_set_error(
mode_id: &'static str,
session_id: Option<&'static str>,
@@ -506,9 +730,9 @@ pub async fn run_model_list<C: Connection>() {
let openai = OpenAiFixture::new(vec![], expected_session_id.clone()).await;
let mut conn = C::new(TestConnectionConfig::default(), openai).await;
let SessionResult {
let SessionData {
session, models, ..
} = conn.new_session().await;
} = conn.new_session().await.unwrap();
expected_session_id.set(&session.session_id().0);
let models = models.unwrap();
@@ -516,7 +740,20 @@ pub async fn run_model_list<C: Connection>() {
assert_eq!(models.current_model_id, ModelId::new(TEST_MODEL));
}
pub async fn run_config_option_model_set<C: Connection>() {
run_model_set_impl::<C>(SetModelVia::ConfigOption).await;
}
pub async fn run_model_set<C: Connection>() {
run_model_set_impl::<C>(SetModelVia::Dedicated).await;
}
enum SetModelVia {
Dedicated,
ConfigOption,
}
async fn run_model_set_impl<C: Connection>(via: SetModelVia) {
let expected_session_id = C::expected_session_id();
let openai = OpenAiFixture::new(
vec![
@@ -535,36 +772,104 @@ pub async fn run_model_set<C: Connection>() {
)
.await;
let mut conn = C::new(TestConnectionConfig::default(), openai).await;
let config = TestConnectionConfig {
strip_config_options: matches!(via, SetModelVia::Dedicated),
..Default::default()
};
let mut conn = C::new(config, openai).await;
// Session A: default model
let SessionResult {
let SessionData {
session: mut session_a,
..
} = conn.new_session().await;
} = conn.new_session().await.unwrap();
// Session B: switch to o4-mini
let SessionResult {
let SessionData {
session: mut session_b,
..
} = conn.new_session().await;
conn.set_model(&session_b.session_id().0, "o4-mini")
.await
.unwrap();
} = conn.new_session().await.unwrap();
let session_id = &session_b.session_id().0;
match via {
SetModelVia::Dedicated => conn.set_model(session_id, "o4-mini").await.unwrap(),
SetModelVia::ConfigOption => conn
.set_config_option(session_id, "model", "o4-mini")
.await
.unwrap(),
}
let set_model_notifs = session_b.notifications();
// Prompt B — expects o4-mini
expected_session_id.set(&session_b.session_id().0);
let output = session_b
.prompt("what is 1+1", PermissionDecision::Cancel)
.await;
.await
.unwrap();
assert_eq!(output.text, "2");
// Prompt A — expects default TEST_MODEL (proves sessions are independent)
// Both model paths emit ConfigOption (no CurrentModelUpdate in the schema).
let prompt_notifs = session_b.notifications();
let mut all = set_model_notifs;
all.extend(prompt_notifs);
assert_eq!(
all,
vec![Notification::ConfigOption, Notification::AgentMessage],
);
// Prompt A: expects default TEST_MODEL (proves sessions are independent)
expected_session_id.set(&session_a.session_id().0);
let output = session_a
.prompt("what is 1+1", PermissionDecision::Cancel)
.await;
.await
.unwrap();
assert_eq!(output.text, "2");
assert_notifications(&session_a.notifications(), &[Notification::AgentMessage]);
}
pub async fn run_model_set_error_session_not_found<C: Connection>() {
let openai = OpenAiFixture::new(vec![], C::expected_session_id()).await;
let mut conn = C::new(TestConnectionConfig::default(), openai).await;
let SessionData { .. } = conn.new_session().await.unwrap();
let err = conn
.set_model("nonexistent-session-id", "o4-mini")
.await
.unwrap_err();
let sacp_err = err.downcast::<sacp::Error>().unwrap();
assert_eq!(
sacp_err,
sacp::Error::resource_not_found(Some("nonexistent-session-id".to_string()))
.data("Session not found: nonexistent-session-id")
);
}
#[allow(dead_code)]
pub async fn run_new_session_error(
cx: &sacp::ConnectionTo<sacp::Agent>,
params: serde_json::Value,
expected: sacp::Error,
) {
let err = fixtures::send_custom(cx, "session/new", params)
.await
.unwrap_err();
assert_eq!(err, expected);
}
pub async fn run_prompt_error<C: Connection>() {
let BasicSession { conn, mut session } =
new_basic_session::<C>(TestConnectionConfig::default()).await;
let sid = session.session_id().0.to_string();
conn.delete_session(&sid).await.unwrap();
let err = session
.prompt("test", PermissionDecision::Cancel)
.await
.unwrap_err();
let sacp_err = err.downcast::<sacp::Error>().unwrap();
assert_eq!(sacp_err.code, sacp::ErrorCode::ResourceNotFound);
}
pub async fn run_permission_persistence<C: Connection>() {
@@ -611,14 +916,14 @@ pub async fn run_permission_persistence<C: Connection>() {
};
let mut conn = C::new(config, openai).await;
let SessionResult { mut session, .. } = conn.new_session().await;
let SessionData { mut session, .. } = conn.new_session().await.unwrap();
expected_session_id.set(&session.session_id().0);
for (decision, expected_status, expected_yaml) in cases {
conn.reset_openai();
conn.reset_permissions();
let _ = fs::remove_file(temp_dir.path().join("permission.yaml"));
let output = session.prompt(prompt, decision).await;
let output = session.prompt(prompt, decision).await.unwrap();
assert_eq!(output.tool_status.unwrap(), expected_status);
assert_eq!(
@@ -641,12 +946,13 @@ pub async fn run_prompt_basic<C: Connection>() {
.await;
let mut conn = C::new(TestConnectionConfig::default(), openai).await;
let SessionResult { mut session, .. } = conn.new_session().await;
let SessionData { mut session, .. } = conn.new_session().await.unwrap();
expected_session_id.set(&session.session_id().0);
let output = session
.prompt("what is 1+1", PermissionDecision::Cancel)
.await;
.await
.unwrap();
assert_eq!(output.text, "2");
assert_notifications(&session.notifications(), &[Notification::AgentMessage]);
expected_session_id.assert_matches(&session.session_id().0);
@@ -685,10 +991,13 @@ pub async fn run_prompt_codemode<C: Connection>() {
let _ = fs::remove_file("/tmp/result.txt");
let mut conn = C::new(config, openai).await;
let SessionResult { mut session, .. } = conn.new_session().await;
let SessionData { mut session, .. } = conn.new_session().await.unwrap();
expected_session_id.set(&session.session_id().0);
let output = session.prompt(prompt, PermissionDecision::Cancel).await;
let output = session
.prompt(prompt, PermissionDecision::Cancel)
.await
.unwrap();
if matches!(output.tool_status, Some(ToolCallStatus::Failed)) || output.text.contains("error") {
panic!("{}", output.text);
}
@@ -722,7 +1031,7 @@ pub async fn run_prompt_image<C: Connection>() {
..Default::default()
};
let mut conn = C::new(config, openai).await;
let SessionResult { mut session, .. } = conn.new_session().await;
let SessionData { mut session, .. } = conn.new_session().await.unwrap();
expected_session_id.set(&session.session_id().0);
let output = session
@@ -730,7 +1039,8 @@ pub async fn run_prompt_image<C: Connection>() {
"Use the get_image tool and describe what you see in its result.",
PermissionDecision::Cancel,
)
.await;
.await
.unwrap();
assert_eq!(output.text, "Hello Goose!\nThis is a test image.");
assert_notifications(
&session.notifications(),
@@ -756,7 +1066,7 @@ pub async fn run_prompt_image_attachment<C: Connection>() {
.await;
let mut conn = C::new(TestConnectionConfig::default(), openai).await;
let SessionResult { mut session, .. } = conn.new_session().await;
let SessionData { mut session, .. } = conn.new_session().await.unwrap();
expected_session_id.set(&session.session_id().0);
let output = session
@@ -766,7 +1076,8 @@ pub async fn run_prompt_image_attachment<C: Connection>() {
"image/png",
PermissionDecision::Cancel,
)
.await;
.await
.unwrap();
assert!(output.text.contains("Hello Goose!"));
assert_notifications(&session.notifications(), &[Notification::AgentMessage]);
expected_session_id.assert_matches(&session.session_id().0);
@@ -795,7 +1106,7 @@ pub async fn run_prompt_mcp<C: Connection>() {
..Default::default()
};
let mut conn = C::new(config, openai).await;
let SessionResult { mut session, .. } = conn.new_session().await;
let SessionData { mut session, .. } = conn.new_session().await.unwrap();
expected_session_id.set(&session.session_id().0);
let output = session
@@ -803,7 +1114,8 @@ pub async fn run_prompt_mcp<C: Connection>() {
"Use the get_code tool and output only its result.",
PermissionDecision::Cancel,
)
.await;
.await
.unwrap();
assert_eq!(output.text, FAKE_CODE);
assert_notifications(
&session.notifications(),
@@ -817,6 +1129,19 @@ pub async fn run_prompt_mcp<C: Connection>() {
expected_session_id.assert_matches(&session.session_id().0);
}
pub async fn run_prompt_model_mismatch<C: Connection>() {
// Start the connection where the current model is not desired.
let config = TestConnectionConfig {
current_model: "o4-mini".to_string(),
..Default::default()
};
// Server starts on o4-mini; client is configured with TEST_MODEL.
// If session_model is seeded from the response, stream() detects the
// mismatch and sends set_model(TEST_MODEL) before prompting.
let BasicSession { conn: _, .. } = new_basic_session::<C>(config).await;
}
pub async fn run_prompt_skill<C: Connection>() {
let cwd = tempfile::tempdir().unwrap();
let skill_dir = cwd.path().join(".agents/skills/test-skill");
@@ -844,12 +1169,13 @@ pub async fn run_prompt_skill<C: Connection>() {
};
let mut conn = C::new(config, openai).await;
let SessionResult { mut session, .. } = conn.new_session().await;
let SessionData { mut session, .. } = conn.new_session().await.unwrap();
expected_session_id.set(&session.session_id().0);
let output = session
.prompt("what is 1+1", PermissionDecision::Cancel)
.await;
.await
.unwrap();
assert_eq!(output.text, "2");
expected_session_id.assert_matches(&session.session_id().0);
}
@@ -877,10 +1203,13 @@ pub async fn run_shell_terminal_false<C: Connection>() {
..Default::default()
};
let mut conn = C::new(config, openai).await;
let SessionResult { mut session, .. } = conn.new_session().await;
let SessionData { mut session, .. } = conn.new_session().await.unwrap();
expected_session_id.set(&session.session_id().0);
let output = session.prompt(&prompt, PermissionDecision::AllowOnce).await;
let output = session
.prompt(&prompt, PermissionDecision::AllowOnce)
.await
.unwrap();
assert!(!output.text.is_empty());
assert_notifications(
&session.notifications(),
@@ -927,10 +1256,13 @@ pub async fn run_shell_terminal_true<C: Connection>() {
..Default::default()
};
let mut conn = C::new(config, openai).await;
let SessionResult { mut session, .. } = conn.new_session().await;
let SessionData { mut session, .. } = conn.new_session().await.unwrap();
expected_session_id.set(&session.session_id().0);
let output = session.prompt(&prompt, PermissionDecision::AllowOnce).await;
let output = session
.prompt(&prompt, PermissionDecision::AllowOnce)
.await
.unwrap();
assert_eq!(output.tool_status, Some(ToolCallStatus::Completed));
assert_notifications(
&session.notifications(),
+13 -89
View File
@@ -1,78 +1,29 @@
#[allow(dead_code)]
mod common_tests;
use common_tests::fixtures::server::ClientToAgentConnection;
use common_tests::fixtures::{run_test, Connection, Session, SessionResult, TestConnectionConfig};
use common_tests::fixtures::server::AcpServerConnection;
use common_tests::fixtures::{
run_test, send_custom, Connection, Session, SessionData, TestConnectionConfig,
};
use goose_test_support::EnforceSessionId;
use std::sync::Arc;
use common_tests::fixtures::OpenAiFixture;
/// Send an untyped custom request and return the result or error.
async fn send_custom(
cx: &sacp::JrConnectionCx<sacp::ClientToAgent>,
method: &str,
params: serde_json::Value,
) -> Result<serde_json::Value, sacp::Error> {
let msg = sacp::UntypedMessage::new(method, params).unwrap();
cx.send_request(msg).block_task().await
}
#[test]
fn test_custom_session_list() {
run_test(async {
let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await;
let mut conn = ClientToAgentConnection::new(TestConnectionConfig::default(), openai).await;
let SessionResult { session, .. } = conn.new_session().await;
let session_id = session.session_id().0.clone();
// Verify the session exists via _session/get
let get_result = send_custom(
conn.cx(),
"_goose/session/get",
serde_json::json!({ "session_id": session_id }),
)
.await;
assert!(
get_result.is_ok(),
"session should exist via get: {:?}",
get_result
);
let get_response = get_result.unwrap();
assert_eq!(
get_response
.get("session")
.and_then(|s| s.get("id"))
.and_then(|v| v.as_str()),
Some(session_id.as_ref()),
);
// Verify _session/list returns a valid response
// Note: list_sessions uses INNER JOIN on messages, so a fresh session
// with no messages won't appear. We just verify the call succeeds.
let result = send_custom(conn.cx(), "_goose/session/list", serde_json::json!({})).await;
assert!(result.is_ok(), "expected ok, got: {:?}", result);
let response = result.unwrap();
let sessions = response.get("sessions").expect("missing 'sessions' field");
assert!(sessions.is_array(), "sessions should be array");
});
}
#[test]
fn test_custom_session_get() {
run_test(async {
let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await;
let mut conn = ClientToAgentConnection::new(TestConnectionConfig::default(), openai).await;
let mut conn = AcpServerConnection::new(TestConnectionConfig::default(), openai).await;
let SessionResult { session, .. } = conn.new_session().await;
let SessionData { session, .. } = conn.new_session().await.unwrap();
let session_id = session.session_id().0.clone();
let result = send_custom(
conn.cx(),
"_goose/session/get",
"session/get",
serde_json::json!({
"session_id": session_id,
"sessionId": session_id,
}),
)
.await;
@@ -87,46 +38,19 @@ fn test_custom_session_get() {
});
}
#[test]
fn test_custom_session_delete() {
run_test(async {
let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await;
let mut conn = ClientToAgentConnection::new(TestConnectionConfig::default(), openai).await;
let SessionResult { session, .. } = conn.new_session().await;
let session_id = session.session_id().0.clone();
let result = send_custom(
conn.cx(),
"_goose/session/delete",
serde_json::json!({ "session_id": session_id }),
)
.await;
assert!(result.is_ok(), "delete failed: {:?}", result);
let result = send_custom(
conn.cx(),
"_goose/session/get",
serde_json::json!({ "session_id": session_id }),
)
.await;
assert!(result.is_err(), "expected error for deleted session");
});
}
#[test]
fn test_custom_get_tools() {
run_test(async {
let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await;
let mut conn = ClientToAgentConnection::new(TestConnectionConfig::default(), openai).await;
let mut conn = AcpServerConnection::new(TestConnectionConfig::default(), openai).await;
let SessionResult { session, .. } = conn.new_session().await;
let SessionData { session, .. } = conn.new_session().await.unwrap();
let session_id = session.session_id().0.clone();
let result = send_custom(
conn.cx(),
"_goose/tools",
serde_json::json!({ "session_id": session_id }),
serde_json::json!({ "sessionId": session_id }),
)
.await;
assert!(result.is_ok(), "expected ok, got: {:?}", result);
@@ -141,7 +65,7 @@ fn test_custom_get_tools() {
fn test_custom_get_extensions() {
run_test(async {
let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await;
let conn = ClientToAgentConnection::new(TestConnectionConfig::default(), openai).await;
let conn = AcpServerConnection::new(TestConnectionConfig::default(), openai).await;
let result =
send_custom(conn.cx(), "_goose/config/extensions", serde_json::json!({})).await;
@@ -163,7 +87,7 @@ fn test_custom_get_extensions() {
fn test_custom_unknown_method() {
run_test(async {
let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await;
let conn = ClientToAgentConnection::new(TestConnectionConfig::default(), openai).await;
let conn = AcpServerConnection::new(TestConnectionConfig::default(), openai).await;
let result = send_custom(conn.cx(), "_unknown/method", serde_json::json!({})).await;
assert!(result.is_err(), "expected method_not_found error");
+58 -22
View File
@@ -5,6 +5,7 @@ use async_trait::async_trait;
use fs_err as fs;
pub use goose::acp::{map_permission_response, PermissionDecision, PermissionMapping};
use goose::builtin_extension::register_builtin_extensions;
use goose::config::paths::Paths;
use goose::config::{GooseMode, PermissionManager};
use goose::providers::api_client::{ApiClient, AuthMethod as ApiAuthMethod};
use goose::providers::base::Provider;
@@ -14,7 +15,7 @@ use goose::session_context::SESSION_ID_HEADER;
use goose_acp::server::{serve, GooseAcpAgent};
use goose_test_support::{ExpectedSessionId, TEST_MODEL};
use sacp::schema::{
AuthMethod, CreateTerminalResponse, KillTerminalCommandResponse, McpServer,
AuthMethod, CreateTerminalResponse, KillTerminalResponse, ListSessionsResponse, McpServer,
ReadTextFileRequest, ReadTextFileResponse, ReleaseTerminalResponse, SessionModeState,
SessionModelState, SessionUpdate, TerminalExitStatus, TerminalId, TerminalOutputResponse,
ToolCallContent, ToolCallStatus, ToolKind, WaitForTerminalExitResponse, WriteTextFileRequest,
@@ -155,13 +156,16 @@ pub async fn spawn_acp_server_in_process(
data_root: &std::path::Path,
goose_mode: GooseMode,
provider_factory: Option<ProviderConstructor>,
current_model: &str,
) -> (DuplexTransport, JoinHandle<()>, Arc<PermissionManager>) {
fs::create_dir_all(data_root).unwrap();
// TODO: Paths::in_state_dir is global, ignoring per-test data_root
fs::create_dir_all(Paths::in_state_dir("logs")).unwrap();
let config_path = data_root.join(goose::config::base::CONFIG_YAML_NAME);
if !config_path.exists() {
fs::write(
&config_path,
format!("GOOSE_MODEL: {TEST_MODEL}\nGOOSE_PROVIDER: openai\n"),
format!("GOOSE_MODEL: {current_model}\nGOOSE_PROVIDER: openai\n"),
)
.unwrap();
}
@@ -180,24 +184,24 @@ pub async fn spawn_acp_server_in_process(
})
});
let agent = Arc::new(
GooseAcpAgent::new(
provider_factory,
builtins.to_vec(),
data_root.to_path_buf(),
data_root.to_path_buf(),
goose_mode,
true,
)
.await
.unwrap(),
);
let agent = GooseAcpAgent::new(
provider_factory,
builtins.to_vec(),
data_root.to_path_buf(),
data_root.to_path_buf(),
goose_mode,
true,
)
.await
.unwrap();
let agent = Arc::new(agent);
let permission_manager = agent.permission_manager();
let (transport, handle) = serve_agent_in_process(agent).await;
(transport, handle, permission_manager)
}
#[derive(Debug)]
pub struct TestOutput {
pub text: String,
pub tool_status: Option<ToolCallStatus>,
@@ -437,11 +441,11 @@ impl TerminalFixture {
ReleaseTerminalResponse::new()
}
pub fn on_kill(&self, terminal_id: &TerminalId) -> KillTerminalCommandResponse {
pub fn on_kill(&self, terminal_id: &TerminalId) -> KillTerminalResponse {
if let Some(TerminalCall::Kill(expected_id)) = self.pop("kill") {
self.validate_terminal_id("kill", &expected_id, terminal_id);
}
KillTerminalCommandResponse::new()
KillTerminalResponse::new()
}
pub fn assert_called(&self) {
@@ -455,7 +459,8 @@ impl TerminalFixture {
}
}
pub struct SessionResult<S> {
#[derive(Debug)]
pub struct SessionData<S> {
pub session: S,
pub models: Option<SessionModelState>,
pub modes: Option<SessionModeState>,
@@ -471,6 +476,11 @@ pub struct TestConnectionConfig {
pub read_text_file: Option<ReadTextFileHandler>,
pub write_text_file: Option<WriteTextFileHandler>,
pub terminal: Option<Arc<TerminalFixture>>,
// When true, strips config_options from responses to test the legacy set_mode/set_model path.
#[allow(dead_code)]
pub strip_config_options: bool,
// The model the server-side provider starts with. Defaults to TEST_MODEL.
pub current_model: String,
}
impl Default for TestConnectionConfig {
@@ -485,6 +495,8 @@ impl Default for TestConnectionConfig {
read_text_file: None,
write_text_file: None,
terminal: None,
strip_config_options: false,
current_model: TEST_MODEL.to_string(),
}
}
}
@@ -495,31 +507,46 @@ pub trait Connection: Sized {
fn expected_session_id() -> Arc<dyn ExpectedSessionId>;
async fn new(config: TestConnectionConfig, openai: OpenAiFixture) -> Self;
async fn new_session(&mut self) -> SessionResult<Self::Session>;
async fn new_session(&mut self) -> anyhow::Result<SessionData<Self::Session>>;
async fn load_session(
&mut self,
session_id: &str,
mcp_servers: Vec<McpServer>,
) -> SessionResult<Self::Session>;
) -> anyhow::Result<SessionData<Self::Session>>;
async fn list_sessions(&self) -> anyhow::Result<ListSessionsResponse>;
async fn close_session(&self, session_id: &str) -> anyhow::Result<()>;
async fn delete_session(&self, session_id: &str) -> anyhow::Result<()>;
async fn set_mode(&self, session_id: &str, mode_id: &str) -> anyhow::Result<()>;
async fn set_model(&self, session_id: &str, model_id: &str) -> anyhow::Result<()>;
async fn set_config_option(
&self,
session_id: &str,
config_id: &str,
value: &str,
) -> anyhow::Result<()>;
fn auth_methods(&self) -> &[AuthMethod];
fn data_root(&self) -> std::path::PathBuf;
fn reset_openai(&self);
fn reset_permissions(&self);
}
#[async_trait]
pub trait Session {
pub trait Session: std::fmt::Debug {
fn session_id(&self) -> &sacp::schema::SessionId;
fn work_dir(&self) -> std::path::PathBuf;
fn notifications(&self) -> Vec<Notification>;
async fn prompt(&mut self, text: &str, decision: PermissionDecision) -> TestOutput;
async fn prompt(
&mut self,
text: &str,
decision: PermissionDecision,
) -> anyhow::Result<TestOutput>;
async fn prompt_with_image(
&mut self,
text: &str,
image_b64: &str,
mime_type: &str,
decision: PermissionDecision,
) -> TestOutput;
) -> anyhow::Result<TestOutput>;
}
#[allow(dead_code)]
@@ -548,5 +575,14 @@ where
}
}
pub async fn send_custom(
cx: &sacp::ConnectionTo<sacp::Agent>,
method: &str,
params: serde_json::Value,
) -> Result<serde_json::Value, sacp::Error> {
let msg = sacp::UntypedMessage::new(method, params).unwrap();
cx.send_request(msg).block_task().await
}
pub mod provider;
pub mod server;
+228 -53
View File
@@ -1,56 +1,82 @@
use super::{
spawn_acp_server_in_process, Connection, OpenAiFixture, PermissionDecision, Session,
SessionResult, TestConnectionConfig, TestOutput,
spawn_acp_server_in_process, Connection, DuplexTransport, OpenAiFixture, PermissionDecision,
Session, SessionData, TestConnectionConfig, TestOutput,
};
use async_trait::async_trait;
use futures::StreamExt;
use goose::acp::{AcpProvider, AcpProviderConfig, PermissionMapping};
use goose::config::goose_mode::GooseMode;
use goose::config::PermissionManager;
use goose::config::{GooseMode, PermissionManager};
use goose::conversation::message::{ActionRequiredData, Message, MessageContent};
use goose::model::ModelConfig;
use goose::permission::permission_confirmation::PrincipalType;
use goose::permission::{Permission, PermissionConfirmation};
use goose::providers::base::Provider;
use goose::providers::errors::ProviderError;
use goose_test_support::{ExpectedSessionId, IgnoreSessionId, TEST_MODEL};
use sacp::schema::{AuthMethod, McpServer, SessionUpdate, ToolCallStatus};
use sacp::schema::{AuthMethod, ListSessionsResponse, McpServer, SessionUpdate, ToolCallStatus};
use sacp::{Channel, Client, ConnectTo, DynConnectTo};
use std::collections::HashMap;
use std::str::FromStr;
use std::sync::Arc;
use strum::VariantNames;
use tokio::sync::Mutex;
pub type NotificationSink = Arc<std::sync::Mutex<Vec<SessionUpdate>>>;
type SessionModels = Arc<std::sync::Mutex<HashMap<String, ModelConfig>>>;
#[allow(dead_code)]
pub struct ClientToProviderConnection {
provider: Arc<Mutex<AcpProvider>>,
pub struct AcpProviderConnection {
/// Option so close_session can trigger session/close via Drop.
provider: Arc<Mutex<Option<AcpProvider>>>,
permission_manager: Arc<PermissionManager>,
auth_methods: Vec<AuthMethod>,
session_counter: usize,
notification_sink: NotificationSink,
session_models: SessionModels,
work_dir: std::path::PathBuf,
data_root: std::path::PathBuf,
_openai: OpenAiFixture,
_temp_dir: Option<tempfile::TempDir>,
_cwd: Option<tempfile::TempDir>,
}
#[allow(dead_code)]
pub struct ClientToProviderSession {
provider: Arc<Mutex<AcpProvider>>,
pub struct AcpProviderSession {
provider: Arc<Mutex<Option<AcpProvider>>>,
session_id: sacp::schema::SessionId,
notification_sink: NotificationSink,
session_models: SessionModels,
work_dir: std::path::PathBuf,
}
impl ClientToProviderSession {
impl std::fmt::Debug for AcpProviderSession {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AcpProviderSession")
.field("session_id", &self.session_id)
.finish()
}
}
impl AcpProviderSession {
#[allow(dead_code)]
async fn send_message(&mut self, message: Message, decision: PermissionDecision) -> TestOutput {
async fn send_message(
&mut self,
message: Message,
decision: PermissionDecision,
) -> anyhow::Result<TestOutput> {
let session_id = self.session_id.0.clone();
let provider = self.provider.lock().await;
let guard = self.provider.lock().await;
let provider = guard.as_ref().unwrap();
self.notification_sink.lock().unwrap().clear();
let model_config = provider.get_model_config();
let model_config = self
.session_models
.lock()
.unwrap()
.get(session_id.as_ref())
.cloned()
.unwrap_or_else(|| provider.get_model_config());
let mut stream = provider
.stream(&model_config, &session_id, "", &[message], &[])
.await
.unwrap();
.await?;
let mut text = String::new();
let mut tool_error = false;
let mut saw_tool = false;
@@ -101,13 +127,13 @@ impl ClientToProviderSession {
None
};
TestOutput { text, tool_status }
Ok(TestOutput { text, tool_status })
}
}
#[async_trait]
impl Connection for ClientToProviderConnection {
type Session = ClientToProviderSession;
impl Connection for AcpProviderConnection {
type Session = AcpProviderSession;
fn expected_session_id() -> Arc<dyn ExpectedSessionId> {
Arc::new(IgnoreSessionId)
@@ -125,12 +151,14 @@ impl Connection for ClientToProviderConnection {
let goose_mode = config.goose_mode;
let mcp_servers = config.mcp_servers;
let current_model = config.current_model.clone();
let (transport, _handle, permission_manager) = spawn_acp_server_in_process(
openai.uri(),
&config.builtins,
data_root.as_path(),
goose_mode,
config.provider_factory,
&current_model,
)
.await;
@@ -138,31 +166,44 @@ impl Connection for ClientToProviderConnection {
.cwd
.as_ref()
.map(|td| td.path().to_path_buf())
.unwrap_or(data_root);
.unwrap_or_else(|| data_root.clone());
let notification_sink: NotificationSink = Arc::new(std::sync::Mutex::new(Vec::new()));
let session_models: SessionModels = Arc::new(std::sync::Mutex::new(HashMap::new()));
let sink_clone = notification_sink.clone();
let provider_config = AcpProviderConfig {
command: "unused".into(),
args: vec![],
env: vec![],
env_remove: vec![],
work_dir: cwd_path,
work_dir: cwd_path.clone(),
mcp_servers,
session_mode_id: None,
mode_mapping: GooseMode::VARIANTS
.iter()
.map(|v| {
let mode = GooseMode::from_str(v).unwrap();
(mode, mode.to_string())
})
.collect(),
permission_mapping: PermissionMapping::default(),
notification_callback: Some(Arc::new(move |n| {
sink_clone.lock().unwrap().push(n.update.clone());
})),
};
// Server always advertises both configOptions and legacy; only the client fallback needs testing.
let transport: DynConnectTo<Client> = if config.strip_config_options {
DynConnectTo::new(strip_config_options(transport))
} else {
DynConnectTo::new(transport)
};
let provider = AcpProvider::connect_with_transport(
"acp-test".to_string(),
ModelConfig::new(TEST_MODEL).unwrap(),
goose_mode,
provider_config,
transport.incoming,
transport.outgoing,
transport,
)
.await
.unwrap();
@@ -170,18 +211,21 @@ impl Connection for ClientToProviderConnection {
let auth_methods = provider.auth_methods().to_vec();
Self {
provider: Arc::new(Mutex::new(provider)),
provider: Arc::new(Mutex::new(Some(provider))),
permission_manager,
auth_methods,
session_counter: 0,
notification_sink,
session_models,
work_dir: cwd_path,
data_root,
_openai: openai,
_temp_dir: temp_dir,
_cwd: config.cwd,
}
}
async fn new_session(&mut self) -> SessionResult<ClientToProviderSession> {
async fn new_session(&mut self) -> anyhow::Result<SessionData<AcpProviderSession>> {
// Tests like run_model_set call new_session() multiple times on the same
// connection, so each needs a distinct key to avoid returning a cached session.
self.session_counter += 1;
@@ -190,58 +234,132 @@ impl Connection for ClientToProviderConnection {
.provider
.lock()
.await
.as_ref()
.unwrap()
.ensure_session(Some(&goose_id))
.await
.unwrap();
.await?;
let session = ClientToProviderSession {
let session = AcpProviderSession {
provider: Arc::clone(&self.provider),
session_id: sacp::schema::SessionId::new(goose_id),
notification_sink: self.notification_sink.clone(),
session_models: self.session_models.clone(),
work_dir: self.work_dir.clone(),
};
SessionResult {
Ok(SessionData {
session,
models: response.models,
modes: response.modes,
}
})
}
async fn load_session(
&mut self,
_session_id: &str,
_mcp_servers: Vec<McpServer>,
) -> SessionResult<ClientToProviderSession> {
unimplemented!("TODO: implement load_session in ACP provider")
) -> anyhow::Result<SessionData<AcpProviderSession>> {
Err(sacp::Error::internal_error()
.data("load_session not implemented for ACP provider")
.into())
}
async fn set_mode(&self, session_id: &str, mode_id: &str) -> anyhow::Result<()> {
let mode = GooseMode::from_str(mode_id).map_err(|_| {
sacp::Error::invalid_params().data(format!("Invalid mode: {}", mode_id))
})?;
async fn list_sessions(&self) -> anyhow::Result<ListSessionsResponse> {
self.provider
.lock()
.await
.update_mode(session_id, mode)
.as_ref()
.unwrap()
.list_sessions()
.await
.map_err(|e| match e {
ProviderError::RequestFailed(msg) => sacp::Error::invalid_params().data(msg),
other => sacp::Error::internal_error().data(other.to_string()),
})?;
}
async fn close_session(&self, _session_id: &str) -> anyhow::Result<()> {
// ACP close exists but SessionManager isn't integrated with it; drop the provider instead.
self.provider.lock().await.take();
Ok(())
}
async fn set_model(&self, session_id: &str, model_id: &str) -> anyhow::Result<()> {
let provider = self.provider.lock().await;
let response = provider.ensure_session(Some(session_id)).await?;
async fn delete_session(&self, session_id: &str) -> anyhow::Result<()> {
self.provider
.lock()
.await
.as_ref()
.unwrap()
.delete_session(session_id)
.await
}
fn data_root(&self) -> std::path::PathBuf {
self.data_root.clone()
}
async fn set_mode(&self, session_id: &str, mode_id: &str) -> anyhow::Result<()> {
let mode = GooseMode::from_str(mode_id)
.map_err(|_| sacp::Error::invalid_params().data(format!("Invalid mode: {mode_id}")))?;
let guard = self.provider.lock().await;
let provider = guard.as_ref().unwrap();
if !provider.has_session(session_id).await {
return Err(
sacp::Error::resource_not_found(Some(session_id.to_string()))
.data(format!("Session not found: {session_id}"))
.into(),
);
}
provider
.send_untyped(
"session/set_model",
serde_json::json!({ "sessionId": response.session_id, "modelId": model_id }),
)
.await?;
.update_mode(session_id, mode)
.await
.map_err(|e| anyhow::anyhow!("{e}"))
}
async fn set_model(&self, session_id: &str, model_id: &str) -> anyhow::Result<()> {
let config = ModelConfig::new(model_id).map_err(|e| anyhow::anyhow!("{e}"))?;
self.session_models
.lock()
.unwrap()
.insert(session_id.to_string(), config);
Ok(())
}
async fn set_config_option(
&self,
session_id: &str,
config_id: &str,
value: &str,
) -> anyhow::Result<()> {
// Check up front because the "model" branch doesn't go through the provider.
let guard = self.provider.lock().await;
let provider = guard.as_ref().unwrap();
if !provider.has_session(session_id).await {
return Err(
sacp::Error::resource_not_found(Some(session_id.to_string()))
.data(format!("Session not found: {session_id}"))
.into(),
);
}
match config_id {
"mode" => {
let mode = GooseMode::from_str(value).map_err(|_| {
sacp::Error::invalid_params().data(format!("Invalid mode: {value}"))
})?;
provider
.update_mode(session_id, mode)
.await
.map_err(|e| anyhow::anyhow!("{e}"))
}
"model" => {
let config = ModelConfig::new(value).map_err(|e| anyhow::anyhow!("{e}"))?;
self.session_models
.lock()
.unwrap()
.insert(session_id.to_string(), config);
Ok(())
}
other => Err(sacp::Error::invalid_params()
.data(format!("Unsupported config option: {other}"))
.into()),
}
}
fn auth_methods(&self) -> &[AuthMethod] {
&self.auth_methods
}
@@ -251,22 +369,31 @@ impl Connection for ClientToProviderConnection {
}
fn reset_permissions(&self) {
// "" matches all extensions, clearing all stored permission decisions
self.permission_manager.remove_extension("");
}
}
#[async_trait]
impl Session for ClientToProviderSession {
impl Session for AcpProviderSession {
fn session_id(&self) -> &sacp::schema::SessionId {
&self.session_id
}
fn work_dir(&self) -> std::path::PathBuf {
self.work_dir.clone()
}
fn notifications(&self) -> Vec<super::Notification> {
let updates = self.notification_sink.lock().unwrap();
let updates: Vec<_> = self.notification_sink.lock().unwrap().drain(..).collect();
super::to_notifications(&updates)
}
async fn prompt(&mut self, prompt: &str, decision: PermissionDecision) -> TestOutput {
async fn prompt(
&mut self,
prompt: &str,
decision: PermissionDecision,
) -> anyhow::Result<TestOutput> {
self.send_message(Message::user().with_text(prompt), decision)
.await
}
@@ -277,10 +404,58 @@ impl Session for ClientToProviderSession {
image_b64: &str,
mime_type: &str,
decision: PermissionDecision,
) -> TestOutput {
) -> anyhow::Result<TestOutput> {
let message = Message::user()
.with_image(image_b64, mime_type)
.with_text(prompt);
self.send_message(message, decision).await
}
}
// Strips config_options from responses so goose falls back to legacy set_mode/set_model.
#[allow(dead_code)]
fn strip_config_options(transport: DuplexTransport) -> Channel {
let (server, server_future) = ConnectTo::<Client>::into_channel_and_future(transport);
let (client_channel, filter) = Channel::duplex();
tokio::spawn(async move {
if let Err(e) = server_future.await {
tracing::error!("config_options filter transport error: {e}");
}
});
tokio::spawn(async move {
let goose_to_server = async {
let mut from_goose = filter.rx;
while let Some(msg) = from_goose.next().await {
if server.tx.unbounded_send(msg).is_err() {
break;
}
}
};
let server_to_goose = async {
let mut from_server = server.rx;
while let Some(msg) = from_server.next().await {
let msg = msg.map(|m| match m {
sacp::jsonrpcmsg::Message::Response(mut resp) => {
if let Some(ref mut result) = resp.result {
if let Some(obj) = result.as_object_mut() {
obj.remove("configOptions");
}
}
sacp::jsonrpcmsg::Message::Response(resp)
}
other => other,
});
if filter.tx.unbounded_send(msg).is_err() {
break;
}
}
};
futures::join!(goose_to_server, server_to_goose);
});
client_channel
}
+146 -84
View File
@@ -1,28 +1,31 @@
use super::{
map_permission_response, spawn_acp_server_in_process, Connection, PermissionDecision,
PermissionMapping, Session, SessionResult, TestConnectionConfig, TestOutput,
PermissionMapping, Session, SessionData, TestConnectionConfig, TestOutput,
};
use async_trait::async_trait;
use goose::config::PermissionManager;
use goose_test_support::{EnforceSessionId, ExpectedSessionId};
use sacp::schema::{
AuthMethod, ClientCapabilities, ContentBlock, CreateTerminalRequest, FileSystemCapability,
ImageContent, InitializeRequest, KillTerminalCommandRequest, LoadSessionRequest, McpServer,
NewSessionRequest, PromptRequest, ProtocolVersion, ReadTextFileRequest, ReleaseTerminalRequest,
RequestPermissionRequest, SessionNotification, SessionUpdate, StopReason,
TerminalOutputRequest, TextContent, ToolCallStatus, WaitForTerminalExitRequest,
WriteTextFileRequest,
AuthMethod, ClientCapabilities, CloseSessionRequest, ContentBlock, CreateTerminalRequest,
FileSystemCapabilities, ImageContent, InitializeRequest, KillTerminalRequest,
ListSessionsRequest, ListSessionsResponse, LoadSessionRequest, McpServer, NewSessionRequest,
PromptRequest, ProtocolVersion, ReadTextFileRequest, ReleaseTerminalRequest,
RequestPermissionRequest, SessionConfigOptionValue, SessionId, SessionModeId,
SessionNotification, SessionUpdate, SetSessionConfigOptionRequest, SetSessionModeRequest,
SetSessionModelRequest, StopReason, TerminalOutputRequest, TextContent, ToolCallStatus,
WaitForTerminalExitRequest, WriteTextFileRequest,
};
use sacp::{ClientToAgent, JrConnectionCx};
use sacp::{Agent, Client, ConnectionTo};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::sync::Notify;
pub struct ClientToAgentConnection {
cx: JrConnectionCx<ClientToAgent>,
pub struct AcpServerConnection {
cx: ConnectionTo<Agent>,
// MCP servers from config, consumed by the first new_session call.
pending_mcp_servers: Vec<McpServer>,
cwd: Option<tempfile::TempDir>,
data_root: std::path::PathBuf,
updates: Arc<Mutex<Vec<SessionNotification>>>,
permission: Arc<Mutex<PermissionDecision>>,
notify: Arc<Notify>,
@@ -32,8 +35,8 @@ pub struct ClientToAgentConnection {
_temp_dir: Option<tempfile::TempDir>,
}
pub struct ClientToAgentSession {
cx: JrConnectionCx<ClientToAgent>,
pub struct AcpServerSession {
cx: ConnectionTo<Agent>,
session_id: sacp::schema::SessionId,
updates: Arc<Mutex<Vec<SessionNotification>>>,
permission: Arc<Mutex<PermissionDecision>>,
@@ -41,12 +44,20 @@ pub struct ClientToAgentSession {
_work_dir: tempfile::TempDir,
}
impl ClientToAgentSession {
impl std::fmt::Debug for AcpServerSession {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AcpServerSession")
.field("session_id", &self.session_id)
.finish()
}
}
impl AcpServerSession {
async fn send_prompt(
&mut self,
content: Vec<ContentBlock>,
decision: PermissionDecision,
) -> TestOutput {
) -> anyhow::Result<TestOutput> {
*self.permission.lock().unwrap() = decision;
self.updates.lock().unwrap().clear();
@@ -54,8 +65,7 @@ impl ClientToAgentSession {
.cx
.send_request(PromptRequest::new(self.session_id.clone(), content))
.block_task()
.await
.unwrap();
.await?;
assert_eq!(response.stop_reason, StopReason::EndTurn);
@@ -73,20 +83,20 @@ impl ClientToAgentSession {
tool_status = extract_tool_status(&self.updates);
}
TestOutput { text, tool_status }
Ok(TestOutput { text, tool_status })
}
}
impl ClientToAgentConnection {
impl AcpServerConnection {
#[allow(dead_code)]
pub fn cx(&self) -> &JrConnectionCx<ClientToAgent> {
pub fn cx(&self) -> &ConnectionTo<Agent> {
&self.cx
}
}
#[async_trait]
impl Connection for ClientToAgentConnection {
type Session = ClientToAgentSession;
impl Connection for AcpServerConnection {
type Session = AcpServerSession;
fn expected_session_id() -> Arc<dyn ExpectedSessionId> {
Arc::new(EnforceSessionId::default())
@@ -107,6 +117,7 @@ impl Connection for ClientToAgentConnection {
data_root.as_path(),
config.goose_mode,
config.provider_factory,
&config.current_model,
)
.await;
@@ -114,7 +125,7 @@ impl Connection for ClientToAgentConnection {
let notify = Arc::new(Notify::new());
let permission = Arc::new(Mutex::new(PermissionDecision::Cancel));
let mut fs_cap = FileSystemCapability::default();
let mut fs_cap = FileSystemCapabilities::default();
if config.read_text_file.is_some() {
fs_cap = fs_cap.read_text_file(true);
}
@@ -130,8 +141,7 @@ impl Connection for ClientToAgentConnection {
let write_handler = config.write_text_file;
let terminal = config.terminal;
let cx_holder: Arc<Mutex<Option<JrConnectionCx<ClientToAgent>>>> =
Arc::new(Mutex::new(None));
let cx_holder: Arc<Mutex<Option<ConnectionTo<Agent>>>> = Arc::new(Mutex::new(None));
let cx_holder_clone = cx_holder.clone();
let auth_holder: Arc<Mutex<Vec<AuthMethod>>> = Arc::new(Mutex::new(Vec::new()));
let auth_holder_clone = auth_holder.clone();
@@ -141,7 +151,8 @@ impl Connection for ClientToAgentConnection {
tokio::spawn(async move {
let permission_mapping = PermissionMapping::default();
let result = ClientToAgent::builder()
let result = Client
.builder()
.on_receive_notification(
{
let updates = updates_clone.clone();
@@ -157,45 +168,42 @@ impl Connection for ClientToAgentConnection {
.on_receive_request(
{
let permission = permission_clone.clone();
async move |req: RequestPermissionRequest,
request_cx,
_connection_cx| {
async move |req: RequestPermissionRequest, responder, _connection_cx| {
let decision = *permission.lock().unwrap();
let response =
map_permission_response(&permission_mapping, &req, decision);
request_cx.respond(response)
responder.respond(response)
}
},
sacp::on_receive_request!(),
)
.on_receive_request(
async move |req: ReadTextFileRequest, request_cx, _cx| match read_handler {
async move |req: ReadTextFileRequest, responder, _cx| match read_handler {
Some(ref rh) => match rh(&req) {
Ok(resp) => request_cx.respond(resp),
Err(msg) => request_cx.respond_with_internal_error(msg),
Ok(resp) => responder.respond(resp),
Err(msg) => responder.respond_with_internal_error(msg),
},
None => request_cx.respond_with_error(sacp::Error::method_not_found()),
None => responder.respond_with_error(sacp::Error::method_not_found()),
},
sacp::on_receive_request!(),
)
.on_receive_request(
async move |req: WriteTextFileRequest, request_cx, _cx| match write_handler
{
async move |req: WriteTextFileRequest, responder, _cx| match write_handler {
Some(ref wh) => match wh(&req) {
Ok(resp) => request_cx.respond(resp),
Err(msg) => request_cx.respond_with_internal_error(msg),
Ok(resp) => responder.respond(resp),
Err(msg) => responder.respond_with_internal_error(msg),
},
None => request_cx.respond_with_error(sacp::Error::method_not_found()),
None => responder.respond_with_error(sacp::Error::method_not_found()),
},
sacp::on_receive_request!(),
)
.on_receive_request(
{
let t = terminal.clone();
async move |req: CreateTerminalRequest, request_cx, _cx| match t {
Some(ref f) => request_cx.respond(f.on_create(&req.command)),
async move |req: CreateTerminalRequest, responder, _cx| match t {
Some(ref f) => responder.respond(f.on_create(&req.command)),
None => {
request_cx.respond_with_error(sacp::Error::method_not_found())
responder.respond_with_error(sacp::Error::method_not_found())
}
}
},
@@ -204,12 +212,12 @@ impl Connection for ClientToAgentConnection {
.on_receive_request(
{
let t = terminal.clone();
async move |req: WaitForTerminalExitRequest, request_cx, _cx| match t {
async move |req: WaitForTerminalExitRequest, responder, _cx| match t {
Some(ref f) => {
request_cx.respond(f.on_wait_for_exit(&req.terminal_id))
responder.respond(f.on_wait_for_exit(&req.terminal_id))
}
None => {
request_cx.respond_with_error(sacp::Error::method_not_found())
responder.respond_with_error(sacp::Error::method_not_found())
}
}
},
@@ -218,10 +226,10 @@ impl Connection for ClientToAgentConnection {
.on_receive_request(
{
let t = terminal.clone();
async move |req: TerminalOutputRequest, request_cx, _cx| match t {
Some(ref f) => request_cx.respond(f.on_output(&req.terminal_id)),
async move |req: TerminalOutputRequest, responder, _cx| match t {
Some(ref f) => responder.respond(f.on_output(&req.terminal_id)),
None => {
request_cx.respond_with_error(sacp::Error::method_not_found())
responder.respond_with_error(sacp::Error::method_not_found())
}
}
},
@@ -230,10 +238,10 @@ impl Connection for ClientToAgentConnection {
.on_receive_request(
{
let t = terminal.clone();
async move |req: ReleaseTerminalRequest, request_cx, _cx| match t {
Some(ref f) => request_cx.respond(f.on_release(&req.terminal_id)),
async move |req: ReleaseTerminalRequest, responder, _cx| match t {
Some(ref f) => responder.respond(f.on_release(&req.terminal_id)),
None => {
request_cx.respond_with_error(sacp::Error::method_not_found())
responder.respond_with_error(sacp::Error::method_not_found())
}
}
},
@@ -242,21 +250,19 @@ impl Connection for ClientToAgentConnection {
.on_receive_request(
{
let t = terminal.clone();
async move |req: KillTerminalCommandRequest, request_cx, _cx| match t {
Some(ref f) => request_cx.respond(f.on_kill(&req.terminal_id)),
async move |req: KillTerminalRequest, responder, _cx| match t {
Some(ref f) => responder.respond(f.on_kill(&req.terminal_id)),
None => {
request_cx.respond_with_error(sacp::Error::method_not_found())
responder.respond_with_error(sacp::Error::method_not_found())
}
}
},
sacp::on_receive_request!(),
)
.connect_to(transport)
.unwrap()
.run_until({
.connect_with(transport, {
let cx_holder = cx_holder_clone;
let auth_holder = auth_holder_clone;
move |cx: JrConnectionCx<ClientToAgent>| async move {
async move |cx: ConnectionTo<Agent>| {
let resp = cx
.send_request(
InitializeRequest::new(ProtocolVersion::LATEST)
@@ -294,6 +300,7 @@ impl Connection for ClientToAgentConnection {
cx,
pending_mcp_servers: config.mcp_servers,
cwd: config.cwd,
data_root,
updates,
permission,
notify,
@@ -304,7 +311,7 @@ impl Connection for ClientToAgentConnection {
}
}
async fn new_session(&mut self) -> SessionResult<ClientToAgentSession> {
async fn new_session(&mut self) -> anyhow::Result<SessionData<AcpServerSession>> {
let work_dir = self
.cwd
.take()
@@ -314,9 +321,8 @@ impl Connection for ClientToAgentConnection {
.cx
.send_request(NewSessionRequest::new(work_dir.path()).mcp_servers(mcp_servers))
.block_task()
.await
.unwrap();
let session = ClientToAgentSession {
.await?;
let session = AcpServerSession {
cx: self.cx.clone(),
session_id: response.session_id.clone(),
updates: self.updates.clone(),
@@ -324,18 +330,18 @@ impl Connection for ClientToAgentConnection {
notify: self.notify.clone(),
_work_dir: work_dir,
};
SessionResult {
Ok(SessionData {
session,
models: response.models,
modes: response.modes,
}
})
}
async fn load_session(
&mut self,
session_id: &str,
mcp_servers: Vec<McpServer>,
) -> SessionResult<ClientToAgentSession> {
) -> anyhow::Result<SessionData<AcpServerSession>> {
self.updates.lock().unwrap().clear();
let work_dir = tempfile::tempdir().unwrap();
let session_id = sacp::schema::SessionId::new(session_id.to_string());
@@ -346,9 +352,8 @@ impl Connection for ClientToAgentConnection {
.mcp_servers(mcp_servers),
)
.block_task()
.await
.unwrap();
let session = ClientToAgentSession {
.await?;
let session = AcpServerSession {
cx: self.cx.clone(),
session_id,
updates: self.updates.clone(),
@@ -356,20 +361,47 @@ impl Connection for ClientToAgentConnection {
notify: self.notify.clone(),
_work_dir: work_dir,
};
SessionResult {
Ok(SessionData {
session,
models: response.models,
modes: response.modes,
}
})
}
async fn list_sessions(&self) -> anyhow::Result<ListSessionsResponse> {
self.cx
.send_request(ListSessionsRequest::new())
.block_task()
.await
.map_err(|e| e.into())
}
async fn close_session(&self, session_id: &str) -> anyhow::Result<()> {
self.cx
.send_request(CloseSessionRequest::new(SessionId::new(session_id)))
.block_task()
.await
.map(|_| ())
.map_err(|e| e.into())
}
async fn delete_session(&self, session_id: &str) -> anyhow::Result<()> {
super::send_custom(
&self.cx,
"session/delete",
serde_json::json!({ "sessionId": session_id }),
)
.await
.map(|_| ())
.map_err(|e| e.into())
}
async fn set_mode(&self, session_id: &str, mode_id: &str) -> anyhow::Result<()> {
let msg = sacp::UntypedMessage::new(
"session/set_mode",
serde_json::json!({ "sessionId": session_id, "modeId": mode_id }),
)?;
self.cx
.send_request(msg)
.send_request(SetSessionModeRequest::new(
SessionId::new(session_id),
SessionModeId::new(mode_id),
))
.block_task()
.await
.map(|_| ())
@@ -377,12 +409,29 @@ impl Connection for ClientToAgentConnection {
}
async fn set_model(&self, session_id: &str, model_id: &str) -> anyhow::Result<()> {
let msg = sacp::UntypedMessage::new(
"session/set_model",
serde_json::json!({ "sessionId": session_id, "modelId": model_id }),
)?;
self.cx
.send_request(msg)
.send_request(SetSessionModelRequest::new(
SessionId::new(session_id),
model_id.to_string(),
))
.block_task()
.await
.map(|_| ())
.map_err(|e| e.into())
}
async fn set_config_option(
&self,
session_id: &str,
config_id: &str,
value: &str,
) -> anyhow::Result<()> {
self.cx
.send_request(SetSessionConfigOptionRequest::new(
SessionId::new(session_id),
config_id.to_string(),
SessionConfigOptionValue::value_id(value.to_string()),
))
.block_task()
.await
.map(|_| ())
@@ -393,33 +442,46 @@ impl Connection for ClientToAgentConnection {
&self.auth_methods
}
fn data_root(&self) -> std::path::PathBuf {
self.data_root.clone()
}
fn reset_openai(&self) {
self._openai.reset();
}
fn reset_permissions(&self) {
// "" matches all extensions, clearing all stored permission decisions
self.permission_manager.remove_extension("");
}
}
#[async_trait]
impl Session for ClientToAgentSession {
impl Session for AcpServerSession {
fn session_id(&self) -> &sacp::schema::SessionId {
&self.session_id
}
fn work_dir(&self) -> std::path::PathBuf {
self._work_dir.path().to_path_buf()
}
fn notifications(&self) -> Vec<super::Notification> {
let updates: Vec<_> = self
.updates
.lock()
.unwrap()
.iter()
.map(|n| n.update.clone())
.drain(..)
.map(|n| n.update)
.collect();
super::to_notifications(&updates)
}
async fn prompt(&mut self, text: &str, decision: PermissionDecision) -> TestOutput {
async fn prompt(
&mut self,
text: &str,
decision: PermissionDecision,
) -> anyhow::Result<TestOutput> {
self.send_prompt(vec![ContentBlock::Text(TextContent::new(text))], decision)
.await
}
@@ -430,7 +492,7 @@ impl Session for ClientToAgentSession {
image_b64: &str,
mime_type: &str,
decision: PermissionDecision,
) -> TestOutput {
) -> anyhow::Result<TestOutput> {
self.send_prompt(
vec![
ContentBlock::Image(ImageContent::new(image_b64, mime_type)),
+80 -27
View File
@@ -1,120 +1,173 @@
#![recursion_limit = "256"]
mod common_tests;
use common_tests::fixtures::provider::ClientToProviderConnection;
use common_tests::fixtures::provider::AcpProviderConnection;
use common_tests::fixtures::run_test;
use common_tests::{
run_config_mcp, run_fs_read_text_file_true, run_fs_write_text_file_false,
run_fs_write_text_file_true, run_initialize_doesnt_hit_provider, run_load_mode, run_load_model,
run_load_session_mcp, run_mode_set, run_model_list, run_model_set, run_permission_persistence,
run_prompt_basic, run_prompt_codemode, run_prompt_image, run_prompt_image_attachment,
run_prompt_mcp, run_prompt_skill, run_shell_terminal_false, run_shell_terminal_true,
run_close_session, run_config_mcp, run_config_option_mode_set, run_config_option_model_set,
run_delete_session, run_fs_read_text_file_true, run_fs_write_text_file_false,
run_fs_write_text_file_true, run_initialize_doesnt_hit_provider, run_list_sessions,
run_load_mode, run_load_model, run_load_session_error, run_load_session_mcp, run_mode_set,
run_model_list, run_model_set, run_model_set_error_session_not_found,
run_permission_persistence, run_prompt_basic, run_prompt_codemode, run_prompt_error,
run_prompt_image, run_prompt_image_attachment, run_prompt_mcp, run_prompt_model_mismatch,
run_prompt_skill, run_shell_terminal_false, run_shell_terminal_true,
};
tests_mode_set_error!(ClientToProviderConnection);
tests_config_option_set_error!(AcpProviderConnection);
tests_mode_set_error!(AcpProviderConnection);
#[test]
fn test_config_mcp() {
run_test(async { run_config_mcp::<ClientToProviderConnection>().await });
run_test(async { run_config_mcp::<AcpProviderConnection>().await });
}
#[test]
fn test_config_option_mode_set() {
run_test(async { run_config_option_mode_set::<AcpProviderConnection>().await });
}
#[test]
fn test_list_sessions() {
run_test(async { run_list_sessions::<AcpProviderConnection>().await });
}
#[test]
fn test_close_session() {
run_test(async { run_close_session::<AcpProviderConnection>().await });
}
#[test]
fn test_config_option_model_set() {
run_test(async { run_config_option_model_set::<AcpProviderConnection>().await });
}
#[test]
#[ignore = "delete is a server-side custom method not routed through the provider"]
fn test_delete_session() {
run_test(async { run_delete_session::<AcpProviderConnection>().await });
}
#[test]
#[ignore = "provider is a plug-in to the goose CLI, UI and terminal clients, none of which handle buffered changes to files"]
fn test_fs_read_text_file_true() {
run_test(async { run_fs_read_text_file_true::<ClientToProviderConnection>().await });
run_test(async { run_fs_read_text_file_true::<AcpProviderConnection>().await });
}
#[test]
fn test_fs_write_text_file_false() {
run_test(async { run_fs_write_text_file_false::<ClientToProviderConnection>().await });
run_test(async { run_fs_write_text_file_false::<AcpProviderConnection>().await });
}
#[test]
#[ignore = "provider is a plug-in to the goose CLI, UI and terminal clients, none of which handle buffered changes to files"]
fn test_fs_write_text_file_true() {
run_test(async { run_fs_write_text_file_true::<ClientToProviderConnection>().await });
run_test(async { run_fs_write_text_file_true::<AcpProviderConnection>().await });
}
#[test]
fn test_initialize_doesnt_hit_provider() {
run_test(async { run_initialize_doesnt_hit_provider::<ClientToProviderConnection>().await });
run_test(async { run_initialize_doesnt_hit_provider::<AcpProviderConnection>().await });
}
#[test]
#[ignore = "TODO: implement load_session in ACP provider"]
fn test_load_mode() {
run_test(async { run_load_mode::<ClientToProviderConnection>().await });
run_test(async { run_load_mode::<AcpProviderConnection>().await });
}
#[test]
#[ignore = "TODO: implement load_session in ACP provider"]
fn test_load_model() {
run_test(async { run_load_model::<ClientToProviderConnection>().await });
run_test(async { run_load_model::<AcpProviderConnection>().await });
}
#[test]
#[ignore = "TODO: implement load_session in ACP provider"]
fn test_load_session_error_session_not_found() {
run_test(async { run_load_session_error::<AcpProviderConnection>().await });
}
#[test]
#[ignore = "TODO: implement load_session in ACP provider"]
fn test_load_session_mcp() {
run_test(async { run_load_session_mcp::<ClientToProviderConnection>().await });
run_test(async { run_load_session_mcp::<AcpProviderConnection>().await });
}
#[test]
fn test_mode_set() {
run_test(async { run_mode_set::<ClientToProviderConnection>().await });
run_test(async { run_mode_set::<AcpProviderConnection>().await });
}
#[test]
fn test_model_list() {
run_test(async { run_model_list::<ClientToProviderConnection>().await });
run_test(async { run_model_list::<AcpProviderConnection>().await });
}
#[test]
fn test_model_set() {
run_test(async { run_model_set::<ClientToProviderConnection>().await });
run_test(async { run_model_set::<AcpProviderConnection>().await });
}
#[test]
#[ignore = "ensure_session lazy-creates sessions so deleted ones reappear"]
fn test_model_set_error_session_not_found() {
run_test(async { run_model_set_error_session_not_found::<AcpProviderConnection>().await });
}
#[test]
fn test_permission_persistence() {
run_test(async { run_permission_persistence::<ClientToProviderConnection>().await });
run_test(async { run_permission_persistence::<AcpProviderConnection>().await });
}
#[test]
fn test_prompt_basic() {
run_test(async { run_prompt_basic::<ClientToProviderConnection>().await });
run_test(async { run_prompt_basic::<AcpProviderConnection>().await });
}
#[test]
fn test_prompt_codemode() {
run_test(async { run_prompt_codemode::<ClientToProviderConnection>().await });
run_test(async { run_prompt_codemode::<AcpProviderConnection>().await });
}
#[test]
#[ignore = "ensure_session lazy-creates sessions so deleted ones reappear"]
fn test_prompt_error_session_not_found() {
run_test(async { run_prompt_error::<AcpProviderConnection>().await });
}
#[test]
fn test_prompt_image() {
run_test(async { run_prompt_image::<ClientToProviderConnection>().await });
run_test(async { run_prompt_image::<AcpProviderConnection>().await });
}
#[test]
fn test_prompt_image_attachment() {
run_test(async { run_prompt_image_attachment::<ClientToProviderConnection>().await });
run_test(async { run_prompt_image_attachment::<AcpProviderConnection>().await });
}
#[test]
fn test_prompt_mcp() {
run_test(async { run_prompt_mcp::<ClientToProviderConnection>().await });
run_test(async { run_prompt_mcp::<AcpProviderConnection>().await });
}
#[test]
fn test_prompt_model_mismatch() {
run_test(async { run_prompt_model_mismatch::<AcpProviderConnection>().await });
}
#[test]
fn test_prompt_skill() {
run_test(async { run_prompt_skill::<ClientToProviderConnection>().await });
run_test(async { run_prompt_skill::<AcpProviderConnection>().await });
}
#[test]
fn test_shell_terminal_false() {
run_test(async { run_shell_terminal_false::<ClientToProviderConnection>().await });
run_test(async { run_shell_terminal_false::<AcpProviderConnection>().await });
}
#[test]
#[ignore = "provider does not handle terminal delegation requests"]
fn test_shell_terminal_true() {
run_test(async { run_shell_terminal_true::<ClientToProviderConnection>().await });
run_test(async { run_shell_terminal_true::<AcpProviderConnection>().await });
}
+76 -27
View File
@@ -1,112 +1,161 @@
mod common_tests;
use common_tests::fixtures::run_test;
use common_tests::fixtures::server::ClientToAgentConnection;
use common_tests::fixtures::server::AcpServerConnection;
use common_tests::{
run_config_mcp, run_fs_read_text_file_true, run_fs_write_text_file_false,
run_fs_write_text_file_true, run_initialize_doesnt_hit_provider, run_load_mode, run_load_model,
run_load_session_mcp, run_mode_set, run_model_list, run_model_set, run_permission_persistence,
run_prompt_basic, run_prompt_codemode, run_prompt_image, run_prompt_image_attachment,
run_prompt_mcp, run_prompt_skill, run_shell_terminal_false, run_shell_terminal_true,
run_close_session, run_config_mcp, run_config_option_mode_set, run_config_option_model_set,
run_delete_session, run_fs_read_text_file_true, run_fs_write_text_file_false,
run_fs_write_text_file_true, run_initialize_doesnt_hit_provider, run_list_sessions,
run_load_mode, run_load_model, run_load_session_error, run_load_session_mcp, run_mode_set,
run_model_list, run_model_set, run_model_set_error_session_not_found,
run_permission_persistence, run_prompt_basic, run_prompt_codemode, run_prompt_error,
run_prompt_image, run_prompt_image_attachment, run_prompt_mcp, run_prompt_model_mismatch,
run_prompt_skill, run_shell_terminal_false, run_shell_terminal_true,
};
tests_mode_set_error!(ClientToAgentConnection);
tests_config_option_set_error!(AcpServerConnection);
tests_mode_set_error!(AcpServerConnection);
#[test]
fn test_config_mcp() {
run_test(async { run_config_mcp::<ClientToAgentConnection>().await });
run_test(async { run_config_mcp::<AcpServerConnection>().await });
}
#[test]
fn test_config_option_mode_set() {
run_test(async { run_config_option_mode_set::<AcpServerConnection>().await });
}
#[test]
fn test_list_sessions() {
run_test(async { run_list_sessions::<AcpServerConnection>().await });
}
#[test]
fn test_close_session() {
run_test(async { run_close_session::<AcpServerConnection>().await });
}
#[test]
fn test_config_option_model_set() {
run_test(async { run_config_option_model_set::<AcpServerConnection>().await });
}
#[test]
fn test_delete_session() {
run_test(async { run_delete_session::<AcpServerConnection>().await });
}
#[test]
fn test_fs_read_text_file_true() {
run_test(async { run_fs_read_text_file_true::<ClientToAgentConnection>().await });
run_test(async { run_fs_read_text_file_true::<AcpServerConnection>().await });
}
#[test]
fn test_fs_write_text_file_false() {
run_test(async { run_fs_write_text_file_false::<ClientToAgentConnection>().await });
run_test(async { run_fs_write_text_file_false::<AcpServerConnection>().await });
}
#[test]
fn test_fs_write_text_file_true() {
run_test(async { run_fs_write_text_file_true::<ClientToAgentConnection>().await });
run_test(async { run_fs_write_text_file_true::<AcpServerConnection>().await });
}
#[test]
fn test_initialize_doesnt_hit_provider() {
run_test(async { run_initialize_doesnt_hit_provider::<ClientToAgentConnection>().await });
run_test(async { run_initialize_doesnt_hit_provider::<AcpServerConnection>().await });
}
#[test]
fn test_load_mode() {
run_test(async { run_load_mode::<ClientToAgentConnection>().await });
run_test(async { run_load_mode::<AcpServerConnection>().await });
}
#[test]
fn test_load_model() {
run_test(async { run_load_model::<ClientToAgentConnection>().await });
run_test(async { run_load_model::<AcpServerConnection>().await });
}
#[test]
fn test_load_session_error_session_not_found() {
run_test(async { run_load_session_error::<AcpServerConnection>().await });
}
#[test]
fn test_load_session_mcp() {
run_test(async { run_load_session_mcp::<ClientToAgentConnection>().await });
run_test(async { run_load_session_mcp::<AcpServerConnection>().await });
}
#[test]
fn test_mode_set() {
run_test(async { run_mode_set::<ClientToAgentConnection>().await });
run_test(async { run_mode_set::<AcpServerConnection>().await });
}
#[test]
fn test_model_list() {
run_test(async { run_model_list::<ClientToAgentConnection>().await });
run_test(async { run_model_list::<AcpServerConnection>().await });
}
#[test]
fn test_model_set() {
run_test(async { run_model_set::<ClientToAgentConnection>().await });
run_test(async { run_model_set::<AcpServerConnection>().await });
}
#[test]
fn test_model_set_error_session_not_found() {
run_test(async { run_model_set_error_session_not_found::<AcpServerConnection>().await });
}
#[test]
fn test_permission_persistence() {
run_test(async { run_permission_persistence::<ClientToAgentConnection>().await });
run_test(async { run_permission_persistence::<AcpServerConnection>().await });
}
#[test]
fn test_prompt_basic() {
run_test(async { run_prompt_basic::<ClientToAgentConnection>().await });
run_test(async { run_prompt_basic::<AcpServerConnection>().await });
}
#[test]
fn test_prompt_codemode() {
run_test(async { run_prompt_codemode::<ClientToAgentConnection>().await });
run_test(async { run_prompt_codemode::<AcpServerConnection>().await });
}
#[test]
fn test_prompt_error_session_not_found() {
run_test(async { run_prompt_error::<AcpServerConnection>().await });
}
#[test]
fn test_prompt_image() {
run_test(async { run_prompt_image::<ClientToAgentConnection>().await });
run_test(async { run_prompt_image::<AcpServerConnection>().await });
}
#[test]
fn test_prompt_image_attachment() {
run_test(async { run_prompt_image_attachment::<ClientToAgentConnection>().await });
run_test(async { run_prompt_image_attachment::<AcpServerConnection>().await });
}
#[test]
fn test_prompt_mcp() {
run_test(async { run_prompt_mcp::<ClientToAgentConnection>().await });
run_test(async { run_prompt_mcp::<AcpServerConnection>().await });
}
#[test]
fn test_prompt_model_mismatch() {
run_test(async { run_prompt_model_mismatch::<AcpServerConnection>().await });
}
#[test]
fn test_prompt_skill() {
run_test(async { run_prompt_skill::<ClientToAgentConnection>().await });
run_test(async { run_prompt_skill::<AcpServerConnection>().await });
}
#[test]
fn test_shell_terminal_false() {
run_test(async { run_shell_terminal_false::<ClientToAgentConnection>().await });
run_test(async { run_shell_terminal_false::<AcpServerConnection>().await });
}
#[test]
fn test_shell_terminal_true() {
run_test(async { run_shell_terminal_true::<ClientToAgentConnection>().await });
run_test(async { run_shell_terminal_true::<AcpServerConnection>().await });
}
+3 -2
View File
@@ -578,7 +578,7 @@ impl CliSession {
}
InputResult::GooseMode(mode) => {
history.save(editor);
self.handle_goose_mode(&mode)?;
self.handle_goose_mode(&mode).await?;
}
InputResult::Plan(options) => {
self.handle_plan_mode(options).await?;
@@ -712,7 +712,7 @@ impl CliSession {
}
}
fn handle_goose_mode(&self, mode: &str) -> Result<()> {
async fn handle_goose_mode(&self, mode: &str) -> Result<()> {
let config = Config::global();
let mode = match GooseMode::from_str(&mode.to_lowercase()) {
Ok(mode) => mode,
@@ -724,6 +724,7 @@ impl CliSession {
return Ok(());
}
};
self.agent.update_goose_mode(mode, &self.session_id).await?;
config.set_goose_mode(mode)?;
output::goose_mode_message(&format!("Goose mode set to '{mode}'"));
Ok(())
+6 -5
View File
@@ -71,6 +71,7 @@ serde_yaml = { workspace = true }
strum = { workspace = true }
once_cell = { workspace = true }
etcetera = { workspace = true }
fs-err = "3"
rand = { workspace = true }
utoipa = { workspace = true, features = ["chrono"] }
tokio-cron-scheduler = "0.14.0"
@@ -103,8 +104,8 @@ tempfile = { workspace = true }
dashmap = "6.1"
ahash = "0.8"
tokio-util = { workspace = true, features = ["compat"] }
sacp = { workspace = true }
agent-client-protocol-schema = { version = "0.10", features = ["unstable"] }
agent-client-protocol-schema = { workspace = true }
sacp = { workspace = true, features = ["unstable"] }
unicode-normalization = "0.1"
# For local Whisper transcription
@@ -188,7 +189,7 @@ path = "src/providers/canonical/build_canonical_models.rs"
ignored = [
# Used only on windows
"winapi",
# Used to provide sacp additional schemas to deserialization
"agent-client-protocol-schema"
"winapi",
# Used to provide extras imports for sacp
"agent-client-protocol-schema",
]
+3 -1
View File
@@ -2,4 +2,6 @@ mod common;
mod provider;
pub use common::{map_permission_response, PermissionDecision, PermissionMapping};
pub use provider::{extension_configs_to_mcp_servers, AcpProvider, AcpProviderConfig};
pub use provider::{
extension_configs_to_mcp_servers, AcpProvider, AcpProviderConfig, ACP_CURRENT_MODEL,
};
File diff suppressed because it is too large Load Diff
+1
View File
@@ -8,6 +8,7 @@ use utoipa::ToSchema;
Debug,
Default,
Eq,
Hash,
PartialEq,
Serialize,
Deserialize,
+16 -24
View File
@@ -1,9 +1,11 @@
use anyhow::Result;
use futures::future::BoxFuture;
use std::collections::HashMap;
use std::path::PathBuf;
use crate::acp::{
extension_configs_to_mcp_servers, AcpProvider, AcpProviderConfig, PermissionMapping,
ACP_CURRENT_MODEL,
};
use crate::config::search_path::SearchPaths;
use crate::config::{Config, GooseMode};
@@ -11,7 +13,6 @@ use crate::model::ModelConfig;
use crate::providers::base::{ProviderDef, ProviderMetadata};
const CLAUDE_ACP_PROVIDER_NAME: &str = "claude-acp";
pub const CLAUDE_ACP_DEFAULT_MODEL: &str = "default";
const CLAUDE_ACP_DOC_URL: &str = "https://github.com/zed-industries/claude-agent-acp";
const CLAUDE_ACP_BINARY: &str = "claude-agent-acp";
@@ -25,7 +26,7 @@ impl ProviderDef for ClaudeAcpProvider {
CLAUDE_ACP_PROVIDER_NAME,
"Claude Code",
"ACP wrapper for Anthropic's Claude. Install: npm install -g @zed-industries/claude-agent-acp",
CLAUDE_ACP_DEFAULT_MODEL,
ACP_CURRENT_MODEL,
vec![],
CLAUDE_ACP_DOC_URL,
vec![],
@@ -51,6 +52,17 @@ impl ProviderDef for ClaudeAcpProvider {
rejected_tool_status: sacp::schema::ToolCallStatus::Failed,
};
let mode_mapping = HashMap::from([
// Closest to "autonomous": bypassPermissions skips confirmations.
(GooseMode::Auto, "bypassPermissions".to_string()),
// Claude Code's default matches "ask before risky actions".
(GooseMode::Approve, "default".to_string()),
// acceptEdits auto-accepts file edits but still prompts for risky ops.
(GooseMode::SmartApprove, "acceptEdits".to_string()),
// Plan mode disables tool execution, aligning with chat-only intent.
(GooseMode::Chat, "plan".to_string()),
]);
let provider_config = AcpProviderConfig {
command: resolved_command,
args: vec![],
@@ -59,7 +71,8 @@ impl ProviderDef for ClaudeAcpProvider {
env_remove: vec!["CLAUDECODE".to_string()],
work_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
mcp_servers: extension_configs_to_mcp_servers(&extensions),
session_mode_id: Some(map_goose_mode(goose_mode)),
session_mode_id: Some(mode_mapping[&goose_mode].clone()),
mode_mapping,
permission_mapping,
notification_callback: None,
};
@@ -69,24 +82,3 @@ impl ProviderDef for ClaudeAcpProvider {
})
}
}
fn map_goose_mode(goose_mode: GooseMode) -> String {
match goose_mode {
GooseMode::Auto => {
// Closest to "autonomous": Claude Code's bypassPermissions skips confirmations.
"bypassPermissions".to_string()
}
GooseMode::Approve => {
// Claude Code's default matches "ask before risky actions".
"default".to_string()
}
GooseMode::SmartApprove => {
// Best-effort: acceptEdits auto-accepts file edits but still prompts for risky ops.
"acceptEdits".to_string()
}
GooseMode::Chat => {
// Plan mode disables tool execution, aligning with chat-only intent.
"plan".to_string()
}
}
}
+12 -2
View File
@@ -1,9 +1,11 @@
use anyhow::Result;
use futures::future::BoxFuture;
use std::collections::HashMap;
use std::path::PathBuf;
use crate::acp::{
extension_configs_to_mcp_servers, AcpProvider, AcpProviderConfig, PermissionMapping,
ACP_CURRENT_MODEL,
};
use crate::config::search_path::SearchPaths;
use crate::config::{Config, GooseMode};
@@ -11,7 +13,6 @@ use crate::model::ModelConfig;
use crate::providers::base::{ProviderDef, ProviderMetadata};
const CODEX_ACP_PROVIDER_NAME: &str = "codex-acp";
pub const CODEX_ACP_DEFAULT_MODEL: &str = "gpt-5.2-codex";
const CODEX_ACP_DOC_URL: &str = "https://github.com/zed-industries/codex-acp";
pub struct CodexAcpProvider;
@@ -24,7 +25,7 @@ impl ProviderDef for CodexAcpProvider {
CODEX_ACP_PROVIDER_NAME,
"Codex CLI",
"ACP adapter for OpenAI's coding assistant. Install: npm install -g @zed-industries/codex-acp",
CODEX_ACP_DEFAULT_MODEL,
ACP_CURRENT_MODEL,
vec![],
CODEX_ACP_DOC_URL,
vec![],
@@ -74,6 +75,14 @@ impl ProviderDef for CodexAcpProvider {
rejected_tool_status: sacp::schema::ToolCallStatus::Failed,
};
// Chat and Approve both map to "read-only".
let mode_mapping = HashMap::from([
(GooseMode::Auto, "full-access".to_string()),
(GooseMode::Approve, "read-only".to_string()),
(GooseMode::SmartApprove, "auto".to_string()),
(GooseMode::Chat, "read-only".to_string()),
]);
let provider_config = AcpProviderConfig {
command: resolved_command,
args,
@@ -83,6 +92,7 @@ impl ProviderDef for CodexAcpProvider {
mcp_servers,
// Disabled until https://github.com/zed-industries/codex-acp/issues/179 is fixed.
session_mode_id: None,
mode_mapping,
permission_mapping,
notification_callback: None,
};
+13 -13
View File
@@ -1,9 +1,11 @@
use anyhow::Result;
use futures::future::BoxFuture;
use std::collections::HashMap;
use std::path::PathBuf;
use crate::acp::{
extension_configs_to_mcp_servers, AcpProvider, AcpProviderConfig, PermissionMapping,
ACP_CURRENT_MODEL,
};
use crate::config::search_path::SearchPaths;
use crate::config::{Config, GooseMode};
@@ -11,7 +13,6 @@ use crate::model::ModelConfig;
use crate::providers::base::{ProviderDef, ProviderMetadata};
const GEMINI_ACP_PROVIDER_NAME: &str = "gemini-acp";
pub const GEMINI_ACP_DEFAULT_MODEL: &str = "default";
const GEMINI_ACP_DOC_URL: &str = "https://github.com/google-gemini/gemini-cli";
pub struct GeminiAcpProvider;
@@ -24,7 +25,7 @@ impl ProviderDef for GeminiAcpProvider {
GEMINI_ACP_PROVIDER_NAME,
"Gemini CLI (ACP)",
"ACP provider for Google's Gemini CLI. Install: npm install -g @google/gemini-cli",
GEMINI_ACP_DEFAULT_MODEL,
ACP_CURRENT_MODEL,
vec![],
GEMINI_ACP_DOC_URL,
vec![],
@@ -48,11 +49,18 @@ impl ProviderDef for GeminiAcpProvider {
};
let mut args = vec!["--acp".to_string()];
if model.model_name != "default" {
if model.model_name != ACP_CURRENT_MODEL {
args.push("--model".to_string());
args.push(model.model_name.clone());
}
let mode_mapping = HashMap::from([
(GooseMode::Auto, "yolo".to_string()),
(GooseMode::Approve, "default".to_string()),
(GooseMode::SmartApprove, "auto_edit".to_string()),
(GooseMode::Chat, "plan".to_string()),
]);
let provider_config = AcpProviderConfig {
command: resolved_command,
args,
@@ -60,7 +68,8 @@ impl ProviderDef for GeminiAcpProvider {
env_remove: vec![],
work_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
mcp_servers: extension_configs_to_mcp_servers(&extensions),
session_mode_id: Some(map_goose_mode(goose_mode)),
session_mode_id: Some(mode_mapping[&goose_mode].clone()),
mode_mapping,
permission_mapping,
notification_callback: None,
};
@@ -70,12 +79,3 @@ impl ProviderDef for GeminiAcpProvider {
})
}
}
fn map_goose_mode(goose_mode: GooseMode) -> String {
match goose_mode {
GooseMode::Auto => "yolo".to_string(),
GooseMode::Approve => "default".to_string(),
GooseMode::SmartApprove => "auto_edit".to_string(),
GooseMode::Chat => "plan".to_string(),
}
}
+3 -3
View File
@@ -5,13 +5,13 @@ use crate::model::ModelConfig;
use crate::providers::errors::ProviderError;
use anyhow::{anyhow, Result};
use base64::Engine;
use fs_err::File;
use regex::Regex;
use reqwest::{Response, StatusCode};
use rmcp::model::{AnnotateAble, ImageContent, RawImageContent};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::fmt::Display;
use std::fs::File;
use std::io::{BufWriter, Read, Write};
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
@@ -436,10 +436,10 @@ impl RequestLog {
let log_path = |i| logs_dir.join(format!("llm_request.{}.jsonl", i));
for i in (0..LOGS_TO_KEEP - 1).rev() {
let _ = std::fs::rename(log_path(i), log_path(i + 1));
let _ = fs_err::rename(log_path(i), log_path(i + 1));
}
std::fs::rename(&self.temp_path, log_path(0))?;
fs_err::rename(&self.temp_path, log_path(0))?;
}
Ok(())
}
+15 -16
View File
@@ -1,6 +1,7 @@
use anyhow::Result;
use dotenvy::dotenv;
use futures::StreamExt;
use goose::acp::ACP_CURRENT_MODEL;
use goose::agents::{Agent, AgentConfig, AgentEvent, GoosePlatform, PromptManager, SessionConfig};
use goose::config::{ExtensionConfig, GooseMode, PermissionManager};
use goose::conversation::message::{ActionRequiredData, Message, MessageContent};
@@ -10,14 +11,11 @@ use goose::providers::anthropic::ANTHROPIC_DEFAULT_MODEL;
use goose::providers::azure::AZURE_DEFAULT_MODEL;
use goose::providers::base::Provider;
use goose::providers::bedrock::BEDROCK_DEFAULT_MODEL;
use goose::providers::claude_acp::CLAUDE_ACP_DEFAULT_MODEL;
use goose::providers::claude_code::CLAUDE_CODE_DEFAULT_MODEL;
use goose::providers::codex::CODEX_DEFAULT_MODEL;
use goose::providers::codex_acp::CODEX_ACP_DEFAULT_MODEL;
use goose::providers::create_with_named_model;
use goose::providers::databricks::DATABRICKS_DEFAULT_MODEL;
use goose::providers::errors::ProviderError;
use goose::providers::gemini_acp::GEMINI_ACP_DEFAULT_MODEL;
use goose::providers::google::GOOGLE_DEFAULT_MODEL;
use goose::providers::litellm::LITELLM_DEFAULT_MODEL;
use goose::providers::openai::OPEN_AI_DEFAULT_MODEL;
@@ -492,11 +490,11 @@ impl ProviderFixture {
println!("===================");
assert!(!models.is_empty());
let model_name = &self.provider.get_model_config().model_name;
// model names may be substrings (e.g. "sonnet" vs "claude-sonnet-4-5-20250929")
let resolved = &self.provider.get_model_config().model_name;
assert_ne!(resolved.as_str(), ACP_CURRENT_MODEL);
assert!(models
.iter()
.any(|m| m == model_name || m.contains(model_name) || model_name.contains(m)));
.any(|m| m == resolved || m.contains(resolved) || resolved.contains(m)));
if let Some(alt) = &self.model_switch_name {
assert!(models
.iter()
@@ -873,20 +871,17 @@ async fn test_codex_provider() -> Result<()> {
// Requires: npm install -g @zed-industries/claude-agent-acp
#[tokio::test]
async fn test_claude_acp_provider() -> Result<()> {
ProviderTestConfig::with_agentic_provider(
"claude-acp",
CLAUDE_ACP_DEFAULT_MODEL,
"claude-agent-acp",
)
.model_switch_name("sonnet")
.run()
.await
ProviderTestConfig::with_agentic_provider("claude-acp", ACP_CURRENT_MODEL, "claude-agent-acp")
.model_switch_name("sonnet")
.run()
.await
}
// Requires: npm install -g @zed-industries/codex-acp
#[tokio::test]
async fn test_codex_acp_provider() -> Result<()> {
ProviderTestConfig::with_agentic_provider("codex-acp", CODEX_ACP_DEFAULT_MODEL, "codex-acp")
ProviderTestConfig::with_agentic_provider("codex-acp", ACP_CURRENT_MODEL, "codex-acp")
.model_switch_name("gpt-5.4-mini")
.run()
.await
}
@@ -894,7 +889,11 @@ async fn test_codex_acp_provider() -> Result<()> {
// Requires: npm install -g @google/gemini-cli
#[tokio::test]
async fn test_gemini_acp_provider() -> Result<()> {
ProviderTestConfig::with_agentic_provider("gemini-acp", GEMINI_ACP_DEFAULT_MODEL, "gemini")
// Don't run tests with ACP_CURRENT_MODEL, as gemini sets "auto-gemini-3" even when the user
// has no access to the Preview Release Channel, resulting in "Requested entity was not found."
// See https://github.com/google-gemini/gemini-cli/issues/22803
ProviderTestConfig::with_agentic_provider("gemini-acp", "auto-gemini-2.5", "gemini")
.model_switch_name("gemini-2.5-flash")
.run()
.await
}
+1 -1
View File
@@ -138,7 +138,7 @@ async function generateClient(meta: { methods: MethodMeta[] }) {
for (const m of meta.methods) {
const fnName = methodToCamelCase(m.method);
const fullMethod = `_goose/${m.method}`;
const fullMethod = m.method;
let paramType = "";
let paramArg = "";
+10 -21
View File
@@ -19,7 +19,6 @@ import type {
GetToolsResponse,
ImportSessionRequest,
ImportSessionResponse,
ListSessionsResponse,
ReadResourceRequest,
ReadResourceResponse,
RemoveExtensionRequest,
@@ -31,70 +30,60 @@ import {
zGetSessionResponse,
zGetToolsResponse,
zImportSessionResponse,
zListSessionsResponse,
zReadResourceResponse,
} from './zod.gen.js';
/**
* Typed client for Goose custom extension methods.
* Wraps an ExtMethodProvider (e.g. ClientSideConnection) with proper types and Zod validation.
*/
export class GooseExtClient {
constructor(private conn: ExtMethodProvider) {}
async extensionsAdd(params: AddExtensionRequest): Promise<void> {
async GooseExtensionsAdd(params: AddExtensionRequest): Promise<void> {
await this.conn.extMethod("_goose/extensions/add", params);
}
async extensionsRemove(params: RemoveExtensionRequest): Promise<void> {
async GooseExtensionsRemove(params: RemoveExtensionRequest): Promise<void> {
await this.conn.extMethod("_goose/extensions/remove", params);
}
async tools(params: GetToolsRequest): Promise<GetToolsResponse> {
async GooseTools(params: GetToolsRequest): Promise<GetToolsResponse> {
const raw = await this.conn.extMethod("_goose/tools", params);
return zGetToolsResponse.parse(raw) as GetToolsResponse;
}
async resourceRead(
async GooseResourceRead(
params: ReadResourceRequest,
): Promise<ReadResourceResponse> {
const raw = await this.conn.extMethod("_goose/resource/read", params);
return zReadResourceResponse.parse(raw) as ReadResourceResponse;
}
async workingDirUpdate(params: UpdateWorkingDirRequest): Promise<void> {
async GooseWorkingDirUpdate(params: UpdateWorkingDirRequest): Promise<void> {
await this.conn.extMethod("_goose/working_dir/update", params);
}
async sessionList(): Promise<ListSessionsResponse> {
const raw = await this.conn.extMethod("_goose/session/list", {});
return zListSessionsResponse.parse(raw) as ListSessionsResponse;
}
async sessionGet(params: GetSessionRequest): Promise<GetSessionResponse> {
const raw = await this.conn.extMethod("_goose/session/get", params);
const raw = await this.conn.extMethod("session/get", params);
return zGetSessionResponse.parse(raw) as GetSessionResponse;
}
async sessionDelete(params: DeleteSessionRequest): Promise<void> {
await this.conn.extMethod("_goose/session/delete", params);
await this.conn.extMethod("session/delete", params);
}
async sessionExport(
async GooseSessionExport(
params: ExportSessionRequest,
): Promise<ExportSessionResponse> {
const raw = await this.conn.extMethod("_goose/session/export", params);
return zExportSessionResponse.parse(raw) as ExportSessionResponse;
}
async sessionImport(
async GooseSessionImport(
params: ImportSessionRequest,
): Promise<ImportSessionResponse> {
const raw = await this.conn.extMethod("_goose/session/import", params);
return zImportSessionResponse.parse(raw) as ImportSessionResponse;
}
async configExtensions(): Promise<GetExtensionsResponse> {
async GooseConfigExtensions(): Promise<GetExtensionsResponse> {
const raw = await this.conn.extMethod("_goose/config/extensions", {});
return zGetExtensionsResponse.parse(raw) as GetExtensionsResponse;
}
+9 -14
View File
@@ -1,38 +1,33 @@
// This file is auto-generated by @hey-api/openapi-ts
export type { AddExtensionRequest, DeleteSessionRequest, EmptyResponse, ExportSessionRequest, ExportSessionResponse, ExtRequest, ExtResponse, GetExtensionsResponse, GetSessionRequest, GetSessionResponse, GetToolsRequest, GetToolsResponse, ImportSessionRequest, ImportSessionResponse, ListSessionsResponse, ReadResourceRequest, ReadResourceResponse, RemoveExtensionRequest, UpdateWorkingDirRequest } from './types.gen.js';
export type { AddExtensionRequest, DeleteSessionRequest, EmptyResponse, ExportSessionRequest, ExportSessionResponse, ExtRequest, ExtResponse, GetExtensionsResponse, GetSessionRequest, GetSessionResponse, GetToolsRequest, GetToolsResponse, ImportSessionRequest, ImportSessionResponse, ReadResourceRequest, ReadResourceResponse, RemoveExtensionRequest, UpdateWorkingDirRequest } from './types.gen.js';
export const GOOSE_EXT_METHODS = [
{
method: "extensions/add",
method: "_goose/extensions/add",
requestType: "AddExtensionRequest",
responseType: "EmptyResponse",
},
{
method: "extensions/remove",
method: "_goose/extensions/remove",
requestType: "RemoveExtensionRequest",
responseType: "EmptyResponse",
},
{
method: "tools",
method: "_goose/tools",
requestType: "GetToolsRequest",
responseType: "GetToolsResponse",
},
{
method: "resource/read",
method: "_goose/resource/read",
requestType: "ReadResourceRequest",
responseType: "ReadResourceResponse",
},
{
method: "working_dir/update",
method: "_goose/working_dir/update",
requestType: "UpdateWorkingDirRequest",
responseType: "EmptyResponse",
},
{
method: "session/list",
requestType: null,
responseType: "ListSessionsResponse",
},
{
method: "session/get",
requestType: "GetSessionRequest",
@@ -44,17 +39,17 @@ export const GOOSE_EXT_METHODS = [
responseType: "EmptyResponse",
},
{
method: "session/export",
method: "_goose/session/export",
requestType: "ExportSessionRequest",
responseType: "ExportSessionResponse",
},
{
method: "session/import",
method: "_goose/session/import",
requestType: "ImportSessionRequest",
responseType: "ImportSessionResponse",
},
{
method: "config/extensions",
method: "_goose/config/extensions",
requestType: null,
responseType: "GetExtensionsResponse",
},
+12 -30
View File
@@ -3,10 +3,9 @@
/**
* Add an extension to an active session.
* Method: `_agent/extensions/add`
*/
export type AddExtensionRequest = {
session_id: string;
sessionId: string;
/**
* Extension configuration (see ExtensionConfig variants: Stdio, StreamableHttp, Builtin, Platform).
*/
@@ -22,19 +21,17 @@ export type EmptyResponse = {
/**
* Remove an extension from an active session.
* Method: `_agent/extensions/remove`
*/
export type RemoveExtensionRequest = {
session_id: string;
sessionId: string;
name: string;
};
/**
* List all tools available in a session.
* Method: `_agent/tools`
*/
export type GetToolsRequest = {
session_id: string;
sessionId: string;
};
export type GetToolsResponse = {
@@ -46,12 +43,11 @@ export type GetToolsResponse = {
/**
* Read a resource from an extension.
* Method: `_agent/resource/read`
*/
export type ReadResourceRequest = {
session_id: string;
sessionId: string;
uri: string;
extension_name: string;
extensionName: string;
};
export type ReadResourceResponse = {
@@ -63,28 +59,18 @@ export type ReadResourceResponse = {
/**
* Update the working directory for a session.
* Method: `_agent/working_dir/update`
*/
export type UpdateWorkingDirRequest = {
session_id: string;
working_dir: string;
};
/**
* List all sessions.
* Method: `_session/list`
*/
export type ListSessionsResponse = {
sessions: Array<unknown>;
sessionId: string;
workingDir: string;
};
/**
* Get a session by ID.
* Method: `_session/get`
*/
export type GetSessionRequest = {
session_id: string;
include_messages?: boolean;
sessionId: string;
includeMessages?: boolean;
};
/**
@@ -99,18 +85,16 @@ export type GetSessionResponse = {
/**
* Delete a session.
* Method: `_session/delete`
*/
export type DeleteSessionRequest = {
session_id: string;
sessionId: string;
};
/**
* Export a session as a JSON string.
* Method: `_session/export`
*/
export type ExportSessionRequest = {
session_id: string;
sessionId: string;
};
export type ExportSessionResponse = {
@@ -119,7 +103,6 @@ export type ExportSessionResponse = {
/**
* Import a session from a JSON string.
* Method: `_session/import`
*/
export type ImportSessionRequest = {
data: string;
@@ -134,7 +117,6 @@ export type ImportSessionResponse = {
/**
* List configured extensions and any warnings.
* Method: `_config/extensions`
*/
export type GetExtensionsResponse = {
/**
@@ -154,7 +136,7 @@ export type ExtRequest = {
export type ExtResponse = {
id: string;
result?: EmptyResponse | GetToolsResponse | ReadResourceResponse | ListSessionsResponse | GetSessionResponse | ExportSessionResponse | ImportSessionResponse | GetExtensionsResponse | unknown;
result?: EmptyResponse | GetToolsResponse | ReadResourceResponse | GetSessionResponse | ExportSessionResponse | ImportSessionResponse | GetExtensionsResponse | unknown;
} | {
error: {
code: number;
+11 -30
View File
@@ -4,10 +4,9 @@ import { z } from 'zod';
/**
* Add an extension to an active session.
* Method: `_agent/extensions/add`
*/
export const zAddExtensionRequest = z.object({
session_id: z.string(),
sessionId: z.string(),
config: z.unknown()
});
@@ -18,19 +17,17 @@ export const zEmptyResponse = z.record(z.unknown());
/**
* Remove an extension from an active session.
* Method: `_agent/extensions/remove`
*/
export const zRemoveExtensionRequest = z.object({
session_id: z.string(),
sessionId: z.string(),
name: z.string()
});
/**
* List all tools available in a session.
* Method: `_agent/tools`
*/
export const zGetToolsRequest = z.object({
session_id: z.string()
sessionId: z.string()
});
export const zGetToolsResponse = z.object({
@@ -39,12 +36,11 @@ export const zGetToolsResponse = z.object({
/**
* Read a resource from an extension.
* Method: `_agent/resource/read`
*/
export const zReadResourceRequest = z.object({
session_id: z.string(),
sessionId: z.string(),
uri: z.string(),
extension_name: z.string()
extensionName: z.string()
});
export const zReadResourceResponse = z.object({
@@ -53,28 +49,18 @@ export const zReadResourceResponse = z.object({
/**
* Update the working directory for a session.
* Method: `_agent/working_dir/update`
*/
export const zUpdateWorkingDirRequest = z.object({
session_id: z.string(),
working_dir: z.string()
});
/**
* List all sessions.
* Method: `_session/list`
*/
export const zListSessionsResponse = z.object({
sessions: z.array(z.unknown())
sessionId: z.string(),
workingDir: z.string()
});
/**
* Get a session by ID.
* Method: `_session/get`
*/
export const zGetSessionRequest = z.object({
session_id: z.string(),
include_messages: z.boolean().optional().default(false)
sessionId: z.string(),
includeMessages: z.boolean().optional().default(false)
});
/**
@@ -86,18 +72,16 @@ export const zGetSessionResponse = z.object({
/**
* Delete a session.
* Method: `_session/delete`
*/
export const zDeleteSessionRequest = z.object({
session_id: z.string()
sessionId: z.string()
});
/**
* Export a session as a JSON string.
* Method: `_session/export`
*/
export const zExportSessionRequest = z.object({
session_id: z.string()
sessionId: z.string()
});
export const zExportSessionResponse = z.object({
@@ -106,7 +90,6 @@ export const zExportSessionResponse = z.object({
/**
* Import a session from a JSON string.
* Method: `_session/import`
*/
export const zImportSessionRequest = z.object({
data: z.string()
@@ -118,7 +101,6 @@ export const zImportSessionResponse = z.object({
/**
* List configured extensions and any warnings.
* Method: `_config/extensions`
*/
export const zGetExtensionsResponse = z.object({
extensions: z.array(z.unknown()),
@@ -155,7 +137,6 @@ export const zExtResponse = z.union([
zEmptyResponse,
zGetToolsResponse,
zReadResourceResponse,
zListSessionsResponse,
zGetSessionResponse,
zExportSessionResponse,
zImportSessionResponse,