Skip to content

Commit 5d502ad

Browse files
committed
Fix merge
2 parents 0e73fa6 + cebf976 commit 5d502ad

File tree

139 files changed

+5446
-1982
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

139 files changed

+5446
-1982
lines changed

.github/workflows/deploy-docs-and-extensions.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,13 @@ jobs:
4646
INKEEP_ORG_ID: ${{ secrets.INKEEP_ORG_ID }}
4747
run: |
4848
npm install
49+
npm test
4950
npm run build
5051
52+
- name: Verify docs map was generated
53+
working-directory: ./documentation
54+
run: ./scripts/verify-build.sh
55+
5156
- name: Checkout gh-pages branch
5257
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
5358
continue-on-error: true # Branch may not exist on first deploy or in forks

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

RELEASE.md

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,29 @@
11
# Making a Release
22

3-
You'll generally create one of two release types: a regular feature release (minor version bump) or a bug-fixing patch release (patch version bump). Regular releases start on main, while patch releases start with an existing release tag. goose uses GitHub actions to automate the creation of release branches. The actual releases are triggered by tags.
3+
You'll generally create one of two release types: a regular feature release (minor version bump like 1.20) or a bug-fixing patch release (patch version bump like 1.20.1).
4+
5+
Regular releases start on main, while patch releases start with an existing release tag. goose uses GitHub actions to automate the creation of release branches. The actual releases are triggered by tags.
6+
For bug-fixing releases, you will cherry-pick fixes into that branch, test, and then release from it.
47

58
## Minor version releases
69

7-
These are typically done once per week. There is an [action](https://github.com/block/goose/actions/workflows/minor-release.yaml) that cuts the branch every Tuesday, but it can also be triggered manually. Commits from main can be cherry-picked into this branch.
10+
These are typically done once per week. There is an [action](https://github.com/block/goose/actions/workflows/minor-release.yaml) that cuts the branch every Tuesday, but it can also be triggered manually. Commits from main can be cherry-picked into this branch as needed before release.
811

912
To trigger the release, find [the corresponding PR](https://github.com/block/goose/pulls?q=is%3Apr+%22chore%28release%29%22+%22%28minor%29%22+author%3Aapp%2Fgithub-actions+) and follow the instructions in the PR description.
1013

1114
## Patch version releases
1215

1316
Minor and patch releases both trigger the creation of a branch for a follow-on patch release. These branches can be used to create patch releases, or can be safely ignored/closed.
17+
You can cherry pick fixes into this branch.
1418

1519
To trigger the release, find [the corresponding PR](https://github.com/block/goose/pulls?q=is%3Apr+%22chore%28release%29%22+%22%28patch%29%22+author%3Aapp%2Fgithub-actions+) and follow the instructions in the PR description.
20+
21+
22+
## High level release flow:
23+
24+
* check out and cherry-pick (if needed) changes to the branch you are going to release (eg the patch branch)
25+
* Test locally if you can (just run-ui)
26+
* Push changes to that branch, wait for build
27+
* Download and test the .zip from the release PR
28+
* If happy, follow the instructions on the release PR to tag and release (tagging will trigger the real release from there)
29+

crates/goose-acp/tests/common.rs

Lines changed: 223 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
use assert_json_diff::{assert_json_matches_no_panic, CompareMode, Config};
2+
use goose::session_context::SESSION_ID_HEADER;
3+
use rmcp::model::{ClientNotification, ClientRequest, Meta, ServerResult};
4+
use rmcp::service::{NotificationContext, RequestContext, ServiceRole};
25
use rmcp::transport::streamable_http_server::{
36
session::local::LocalSessionManager, StreamableHttpServerConfig, StreamableHttpService,
47
};
58
use rmcp::{
69
handler::server::router::tool::ToolRouter, model::*, tool, tool_handler, tool_router,
7-
ErrorData as McpError, ServerHandler,
10+
ErrorData as McpError, RoleServer, ServerHandler, Service,
811
};
912
use std::collections::VecDeque;
1013
use std::sync::{Arc, Mutex};
@@ -14,57 +17,128 @@ use wiremock::{Mock, MockServer, ResponseTemplate};
1417

1518
pub const FAKE_CODE: &str = "test-uuid-12345-67890";
1619

17-
/// Mock OpenAI streaming endpoint. Exchanges are (pattern, response) pairs.
18-
/// On mismatch, returns 417 of the diff in OpenAI error format.
19-
pub async fn setup_mock_openai(exchanges: Vec<(String, &'static str)>) -> MockServer {
20-
let mock_server = MockServer::start().await;
21-
let queue: VecDeque<(String, &'static str)> = exchanges.into_iter().collect();
22-
let queue = Arc::new(Mutex::new(queue));
23-
24-
Mock::given(method("POST"))
25-
.and(path("/v1/chat/completions"))
26-
.respond_with({
27-
let queue = queue.clone();
28-
move |req: &wiremock::Request| {
29-
let body = String::from_utf8_lossy(&req.body);
30-
31-
// Special case session rename request which doesn't happen in a predictable order.
32-
if body.contains("Reply with only a description in four words or less") {
33-
return ResponseTemplate::new(200)
34-
.insert_header("content-type", "application/json")
35-
.set_body_string(include_str!(
36-
"./test_data/openai_session_description.json"
37-
));
38-
}
20+
const NOT_YET_SET: &str = "session-id-not-yet-set";
3921

40-
let (expected, response) = {
41-
let mut q = queue.lock().unwrap();
42-
q.pop_front().unwrap_or_default()
43-
};
22+
#[derive(Clone)]
23+
pub struct ExpectedSessionId {
24+
value: Arc<Mutex<String>>,
25+
errors: Arc<Mutex<Vec<String>>>,
26+
}
4427

45-
if body.contains(&expected) && !expected.is_empty() {
46-
return ResponseTemplate::new(200)
47-
.insert_header("content-type", "text/event-stream")
48-
.set_body_string(response);
49-
}
28+
impl Default for ExpectedSessionId {
29+
fn default() -> Self {
30+
Self {
31+
value: Arc::new(Mutex::new(NOT_YET_SET.to_string())),
32+
errors: Arc::new(Mutex::new(Vec::new())),
33+
}
34+
}
35+
}
36+
37+
impl ExpectedSessionId {
38+
pub fn set(&self, id: &sacp::schema::SessionId) {
39+
*self.value.lock().unwrap() = id.0.to_string();
40+
}
41+
42+
pub fn validate(&self, actual: Option<&str>) -> Result<(), String> {
43+
let expected = self.value.lock().unwrap();
5044

51-
// Coerce non-json to allow a uniform JSON diff error response.
52-
let exp = serde_json::from_str(&expected)
53-
.unwrap_or(serde_json::Value::String(expected.clone()));
54-
let act = serde_json::from_str(&body)
55-
.unwrap_or(serde_json::Value::String(body.to_string()));
56-
let diff =
57-
assert_json_matches_no_panic(&exp, &act, Config::new(CompareMode::Strict))
58-
.unwrap_err();
59-
ResponseTemplate::new(417)
60-
.insert_header("content-type", "text/event-stream")
61-
.set_body_json(serde_json::json!({"error": {"message": diff}}))
45+
let err = match actual {
46+
Some(act) if act == *expected => None,
47+
_ => Some(format!(
48+
"{} mismatch: expected '{}', got {:?}",
49+
SESSION_ID_HEADER, expected, actual
50+
)),
51+
};
52+
match err {
53+
Some(e) => {
54+
self.errors.lock().unwrap().push(e.clone());
55+
Err(e)
6256
}
63-
})
64-
.mount(&mock_server)
65-
.await;
57+
None => Ok(()),
58+
}
59+
}
6660

67-
mock_server
61+
/// Calling this ensures incidental requests that might error asynchronously, such as
62+
/// session rename have coherent session IDs.
63+
pub fn assert_no_errors(&self) {
64+
let e = self.errors.lock().unwrap();
65+
assert!(e.is_empty(), "Session ID validation errors: {:?}", *e);
66+
}
67+
}
68+
69+
pub struct OpenAiFixture {
70+
pub server: MockServer,
71+
}
72+
73+
impl OpenAiFixture {
74+
/// Mock OpenAI streaming endpoint. Exchanges are (pattern, response) pairs.
75+
/// On mismatch, returns 417 of the diff in OpenAI error format.
76+
pub async fn new(
77+
exchanges: Vec<(String, &'static str)>,
78+
expected_session_id: ExpectedSessionId,
79+
) -> Self {
80+
let mock_server = MockServer::start().await;
81+
let queue: VecDeque<(String, &'static str)> = exchanges.into_iter().collect();
82+
let queue = Arc::new(Mutex::new(queue));
83+
84+
Mock::given(method("POST"))
85+
.and(path("/v1/chat/completions"))
86+
.respond_with({
87+
let queue = queue.clone();
88+
let expected_session_id = expected_session_id.clone();
89+
move |req: &wiremock::Request| {
90+
let body = String::from_utf8_lossy(&req.body);
91+
92+
let actual = req
93+
.headers
94+
.get(SESSION_ID_HEADER)
95+
.and_then(|v| v.to_str().ok());
96+
if let Err(e) = expected_session_id.validate(actual) {
97+
return ResponseTemplate::new(417)
98+
.insert_header("content-type", "application/json")
99+
.set_body_json(serde_json::json!({"error": {"message": e}}));
100+
}
101+
102+
// Session rename (async, unpredictable order) - canned response
103+
if body.contains("Reply with only a description in four words or less") {
104+
return ResponseTemplate::new(200)
105+
.insert_header("content-type", "application/json")
106+
.set_body_string(include_str!(
107+
"./test_data/openai_session_description.json"
108+
));
109+
}
110+
111+
let (expected_body, response) = {
112+
let mut q = queue.lock().unwrap();
113+
q.pop_front().unwrap_or_default()
114+
};
115+
116+
if body.contains(&expected_body) && !expected_body.is_empty() {
117+
return ResponseTemplate::new(200)
118+
.insert_header("content-type", "text/event-stream")
119+
.set_body_string(response);
120+
}
121+
122+
// Coerce non-json to allow a uniform JSON diff error response.
123+
let exp = serde_json::from_str(&expected_body)
124+
.unwrap_or(serde_json::Value::String(expected_body.clone()));
125+
let act = serde_json::from_str(&body)
126+
.unwrap_or(serde_json::Value::String(body.to_string()));
127+
let diff =
128+
assert_json_matches_no_panic(&exp, &act, Config::new(CompareMode::Strict))
129+
.unwrap_err();
130+
ResponseTemplate::new(417)
131+
.insert_header("content-type", "application/json")
132+
.set_body_json(serde_json::json!({"error": {"message": diff}}))
133+
}
134+
})
135+
.mount(&mock_server)
136+
.await;
137+
138+
Self {
139+
server: mock_server,
140+
}
141+
}
68142
}
69143

70144
#[derive(Clone)]
@@ -108,20 +182,108 @@ impl ServerHandler for Lookup {
108182
}
109183
}
110184

111-
pub async fn spawn_mcp_http_server() -> (String, JoinHandle<()>) {
112-
let service = StreamableHttpService::new(
113-
|| Ok(Lookup::new()),
114-
LocalSessionManager::default().into(),
115-
StreamableHttpServerConfig::default(),
116-
);
117-
let router = axum::Router::new().nest_service("/mcp", service);
118-
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
119-
let addr = listener.local_addr().unwrap();
120-
let url = format!("http://{addr}/mcp");
185+
trait HasMeta {
186+
fn meta(&self) -> &Meta;
187+
}
188+
189+
impl<R: ServiceRole> HasMeta for RequestContext<R> {
190+
fn meta(&self) -> &Meta {
191+
&self.meta
192+
}
193+
}
194+
195+
impl<R: ServiceRole> HasMeta for NotificationContext<R> {
196+
fn meta(&self) -> &Meta {
197+
&self.meta
198+
}
199+
}
200+
201+
pub struct ValidatingService<S> {
202+
inner: S,
203+
expected_session_id: ExpectedSessionId,
204+
}
205+
206+
impl<S> ValidatingService<S> {
207+
pub fn new(inner: S, expected_session_id: ExpectedSessionId) -> Self {
208+
Self {
209+
inner,
210+
expected_session_id,
211+
}
212+
}
213+
214+
fn validate<C: HasMeta>(&self, context: &C) -> Result<(), McpError> {
215+
let actual = context
216+
.meta()
217+
.0
218+
.get(SESSION_ID_HEADER)
219+
.and_then(|v| v.as_str());
220+
self.expected_session_id
221+
.validate(actual)
222+
.map_err(|e| McpError::new(ErrorCode::INVALID_REQUEST, e, None))
223+
}
224+
}
225+
226+
impl<S: Service<RoleServer>> Service<RoleServer> for ValidatingService<S> {
227+
async fn handle_request(
228+
&self,
229+
request: ClientRequest,
230+
context: RequestContext<RoleServer>,
231+
) -> Result<ServerResult, McpError> {
232+
if !matches!(request, ClientRequest::InitializeRequest(_)) {
233+
self.validate(&context)?;
234+
}
235+
self.inner.handle_request(request, context).await
236+
}
237+
238+
async fn handle_notification(
239+
&self,
240+
notification: ClientNotification,
241+
context: NotificationContext<RoleServer>,
242+
) -> Result<(), McpError> {
243+
if !matches!(notification, ClientNotification::InitializedNotification(_)) {
244+
self.validate(&context).ok();
245+
}
246+
self.inner.handle_notification(notification, context).await
247+
}
248+
249+
fn get_info(&self) -> ServerInfo {
250+
self.inner.get_info()
251+
}
252+
}
121253

122-
let handle = tokio::spawn(async move {
123-
axum::serve(listener, router).await.unwrap();
124-
});
254+
pub struct McpFixture {
255+
pub url: String,
256+
// Keep the server alive in tests; underscore avoids unused field warnings.
257+
_handle: JoinHandle<()>,
258+
}
259+
260+
impl McpFixture {
261+
pub async fn new(expected_session_id: ExpectedSessionId) -> Self {
262+
let service = StreamableHttpService::new(
263+
{
264+
let expected_session_id = expected_session_id.clone();
265+
move || {
266+
Ok(ValidatingService::new(
267+
Lookup::new(),
268+
expected_session_id.clone(),
269+
))
270+
}
271+
},
272+
LocalSessionManager::default().into(),
273+
StreamableHttpServerConfig::default(),
274+
);
275+
let router = axum::Router::new().nest_service("/mcp", service);
276+
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
277+
let addr = listener.local_addr().unwrap();
278+
let url = format!("http://{addr}/mcp");
125279

126-
(url, handle)
280+
let handle = tokio::spawn(async move {
281+
axum::serve(listener, router).await.unwrap();
282+
});
283+
284+
Self {
285+
url,
286+
_handle: handle,
287+
}
288+
}
127289
}

0 commit comments

Comments
 (0)