main
rs 795 lines 28.5 KB
Raw
1 //! The ACP v1 conformance suite (Onde Agent Platform §5.5). Each function drives the real
2 //! agent binary over stdio against the mock Onde endpoint. Run them all with
3 //! [`conformance_suite!`](crate::conformance_suite).
4 //!
5 //! The suite assumes the agent offers [`WorkspaceTools`] (`read_file`, `write_file`) and
6 //! terminal auth, as every Onde ACP agent does today.
7 //!
8 //! [`WorkspaceTools`]: https://docs.rs/ed-acp
9
10 use std::time::Duration;
11
12 use serde_json::{Value, json};
13
14 use crate::Target;
15 use crate::mock::{MODEL, Step, TEST_KEY, mock, temp_dir};
16 use crate::raw::{Perm, Raw, alive, error_code, read_pid, stub_server, tool_text, wait_dead};
17
18 pub async fn initialize_negotiates_v1_and_advertises_capabilities(t: &Target) {
19 let data = temp_dir("sf-data");
20 let (url, _) = mock(vec![Step::Final("x")]).await;
21 let mut agent = Raw::spawn(&t.env(&data, &url));
22 // A client from the future gets v1 back, not an echo of its own version.
23 let r = agent
24 .ok(
25 "initialize",
26 json!({"protocolVersion": 99, "clientCapabilities": {"auth": {"terminal": true}}}),
27 )
28 .await;
29 assert_eq!(r["protocolVersion"], 1);
30 let caps = &r["agentCapabilities"];
31 assert_eq!(caps["loadSession"], true);
32 assert_eq!(caps["promptCapabilities"]["image"], true);
33 assert_eq!(caps["promptCapabilities"]["embeddedContext"], true);
34 let sc = &caps["sessionCapabilities"];
35 for k in ["list", "resume", "close", "delete", "additionalDirectories"] {
36 assert!(sc.get(k).is_some(), "sessionCapabilities.{k} missing: {sc}");
37 }
38 assert_eq!(r["agentInfo"]["name"], t.name);
39 assert!(
40 caps["auth"].get("logout").is_some(),
41 "auth.logout missing: {caps}"
42 );
43 // Terminal auth is offered to clients that declare they can run it...
44 assert_eq!(r["authMethods"][0]["id"], "terminal-setup");
45 assert_eq!(r["authMethods"][0]["args"][0], "--setup");
46 agent.finish().await;
47
48 let mut agent = Raw::spawn(&t.env(&data, &url));
49 let r = agent
50 .ok(
51 "initialize",
52 json!({"protocolVersion": 1, "clientCapabilities": {}}),
53 )
54 .await;
55 assert_eq!(r["protocolVersion"], 1);
56 // ...and not to clients that can't.
57 assert!(
58 r.get("authMethods")
59 .and_then(Value::as_array)
60 .is_none_or(|m| m.is_empty()),
61 "{r}"
62 );
63 agent.finish().await;
64 }
65
66 /// The ACP registry probes every listed agent with this exact `initialize` (copied from
67 /// `.github/workflows/client.py` in agentclientprotocol/registry), from an empty HOME with no
68 /// credentials, and rejects the agent if `authMethods` is empty. It signals terminal-auth
69 /// support only through `_meta`, never through `auth.terminal`.
70 pub async fn registry_validator_probe_sees_terminal_auth(t: &Target) {
71 let data = temp_dir("sf-data");
72 let (url, _) = mock(vec![Step::Final("x")]).await;
73 let mut env = t.env(&data, &url);
74 env.unset("ONDE_API_KEY");
75 let mut agent = Raw::spawn(&env);
76 let r = agent
77 .ok(
78 "initialize",
79 json!({
80 "protocolVersion": 1,
81 "clientInfo": {"name": "ACP Registry Validator", "version": "1.0.0"},
82 "clientCapabilities": {
83 "terminal": true,
84 "fs": {"readTextFile": true, "writeTextFile": true},
85 "_meta": {"terminal_output": true, "terminal-auth": true}
86 }
87 }),
88 )
89 .await;
90 let methods = r["authMethods"].as_array().expect("authMethods");
91 assert!(!methods.is_empty(), "registry validation would fail: {r}");
92 assert_eq!(methods[0]["type"], "terminal", "{methods:?}");
93 assert_eq!(methods[0]["args"][0], "--setup");
94 agent.finish().await;
95 }
96
97 pub async fn auth_required_then_authenticate_reloads_config(t: &Target) {
98 let data = temp_dir("sf-data");
99 let (url, _) = mock(vec![Step::Final("hello")]).await;
100 let mut env = t.env(&data, &url);
101 env.unset("ONDE_API_KEY");
102 let mut agent = Raw::spawn(&env);
103 agent.initialize().await;
104 let cwd = temp_dir("sf-work");
105
106 // No key anywhere: session/new and session/prompt are refused with auth_required (-32000).
107 let e = agent
108 .call("session/new", json!({"cwd": cwd, "mcpServers": []}))
109 .await
110 .unwrap_err();
111 assert_eq!(error_code(&e), -32000, "{e}");
112 let e = agent
113 .call("session/prompt", json!({"sessionId": "x", "prompt": []}))
114 .await
115 .unwrap_err();
116 assert_eq!(error_code(&e), -32000, "{e}");
117 // authenticate fails while there is still no key, and rejects unknown methods.
118 let e = agent
119 .call("authenticate", json!({"methodId": "terminal-setup"}))
120 .await
121 .unwrap_err();
122 assert_eq!(error_code(&e), -32000, "{e}");
123 let e = agent
124 .call("authenticate", json!({"methodId": "nope"}))
125 .await
126 .unwrap_err();
127 assert_eq!(error_code(&e), -32602, "{e}");
128
129 // load/resume also answer auth_required, so an editor reopening a thread shows sign-in.
130 let e = agent
131 .call(
132 "session/load",
133 json!({"sessionId": "x", "cwd": cwd, "mcpServers": []}),
134 )
135 .await
136 .unwrap_err();
137 assert_eq!(error_code(&e), -32000, "{e}");
138
139 // `--setup` stored a key in the config file; authenticate picks it up without a restart.
140 std::fs::write(data.join("env"), format!("ONDE_API_KEY={TEST_KEY}\n")).unwrap();
141 agent
142 .ok("authenticate", json!({"methodId": "terminal-setup"}))
143 .await;
144 let sid = agent.new_session(&cwd, json!([])).await;
145 let r = agent.text_prompt(&sid, "hi").await;
146 assert_eq!(r["stopReason"], "end_turn");
147
148 // logout removes the stored key; the agent is signed out without a restart.
149 agent.ok("logout", json!({})).await;
150 assert!(!data.join("env").exists());
151 let e = agent
152 .call("session/new", json!({"cwd": cwd, "mcpServers": []}))
153 .await
154 .unwrap_err();
155 assert_eq!(error_code(&e), -32000, "{e}");
156
157 // Zed's flow: terminal auth runs `--setup` in another process, then retries session/new
158 // on this one without calling authenticate. The stored key must be picked up anyway.
159 std::fs::write(data.join("env"), format!("ONDE_API_KEY={TEST_KEY}\n")).unwrap();
160 agent.new_session(&cwd, json!([])).await;
161 agent.finish().await;
162 }
163
164 pub async fn sessions_survive_a_killed_process_and_replay_tool_output(t: &Target) {
165 let data = temp_dir("sf-data");
166 let work = temp_dir("sf-work");
167 std::fs::write(work.join("notes.txt"), "chorus in Dm").unwrap();
168 let (url, _) = mock(vec![
169 Step::ToolCall {
170 name: "read_file",
171 arguments: json!({"path": "notes.txt"}),
172 },
173 Step::ToolCall {
174 name: "read_file",
175 arguments: json!({"path": "missing.txt"}),
176 },
177 Step::Final("The chorus is in Dm."),
178 ])
179 .await;
180 let env = t.env(&data, &url);
181
182 let mut agent = Raw::spawn(&env);
183 agent.initialize().await;
184 let sid = agent.new_session(&work, json!([])).await;
185 let r = agent.text_prompt(&sid, "What key is the chorus in?").await;
186 assert_eq!(r["stopReason"], "end_turn");
187 // Title is pushed as a session_info_update.
188 let infos = agent.updates("session_info_update");
189 assert!(
190 infos
191 .iter()
192 .any(|u| u["title"] == "What key is the chorus in?"),
193 "no title update: {infos:?}"
194 );
195 // The process dies without a graceful close.
196 agent.child.kill().await.unwrap();
197 drop(agent);
198
199 let mut agent = Raw::spawn(&env);
200 agent.initialize().await;
201 let list = agent.ok("session/list", json!({})).await;
202 let sessions = list["sessions"].as_array().unwrap();
203 assert_eq!(sessions.len(), 1, "{list}");
204 assert_eq!(sessions[0]["sessionId"], sid.as_str());
205 assert_eq!(sessions[0]["title"], "What key is the chorus in?");
206 assert!(sessions[0]["updatedAt"].as_str().unwrap().ends_with('Z'));
207 // cwd filter.
208 let other = agent.ok("session/list", json!({"cwd": "/nowhere"})).await;
209 assert!(other["sessions"].as_array().unwrap().is_empty());
210
211 agent.notes.clear();
212 agent
213 .ok(
214 "session/load",
215 json!({"sessionId": sid, "cwd": work, "mcpServers": []}),
216 )
217 .await;
218 agent.settle().await;
219 let user = agent.updates("user_message_chunk");
220 assert_eq!(user[0]["content"]["text"], "What key is the chorus in?");
221 let said: String = agent
222 .updates("agent_message_chunk")
223 .iter()
224 .filter_map(|u| u["content"]["text"].as_str())
225 .collect();
226 assert!(said.contains("The chorus is in Dm."), "{said}");
227 let calls = agent.updates("tool_call");
228 assert_eq!(calls.len(), 2, "{calls:?}");
229 assert_eq!(calls[0]["status"], "completed");
230 assert!(
231 tool_text(&calls[0]).contains("chorus in Dm"),
232 "{}",
233 calls[0]
234 );
235 assert_eq!(
236 calls[1]["status"], "failed",
237 "failed call must replay as failed: {}",
238 calls[1]
239 );
240 assert!(
241 calls[0]["locations"][0]["path"]
242 .as_str()
243 .unwrap()
244 .ends_with("notes.txt")
245 );
246 // Commands are advertised after the load response.
247 let cmds = agent.updates("available_commands_update");
248 assert!(!cmds.is_empty());
249 agent.finish().await;
250 }
251
252 pub async fn resume_close_delete_and_unknown_sessions(t: &Target) {
253 let data = temp_dir("sf-data");
254 let work = temp_dir("sf-work");
255 let work2 = temp_dir("sf-work");
256 let (url, stats) = mock(vec![Step::Final("ok")]).await;
257 let env = t.env(&data, &url);
258 let mut agent = Raw::spawn(&env);
259 agent.initialize().await;
260
261 let sid = agent.new_session(&work, json!([])).await;
262 agent.text_prompt(&sid, "first").await;
263
264 // Unknown sessions are invalid_params where nothing can be reopened. load/resume of a
265 // well-formed id reopen it instead (covered below); a malformed id can't name a session.
266 for (method, params) in [
267 (
268 "session/load",
269 json!({"sessionId": "../nope", "cwd": work, "mcpServers": []}),
270 ),
271 (
272 "session/resume",
273 json!({"sessionId": "../nope", "cwd": work, "mcpServers": []}),
274 ),
275 (
276 "session/load",
277 json!({"sessionId": "relative-cwd", "cwd": "work", "mcpServers": []}),
278 ),
279 ("session/new", json!({"cwd": "work", "mcpServers": []})),
280 ("session/close", json!({"sessionId": "nope"})),
281 ("session/list", json!({"cursor": "stale"})),
282 (
283 "session/prompt",
284 json!({"sessionId": "nope", "prompt": [{"type": "text", "text": "x"}]}),
285 ),
286 (
287 "session/set_config_option",
288 json!({"sessionId": "nope", "configId": "model", "value": "other-model"}),
289 ),
290 ] {
291 let e = agent.call(method, params).await.unwrap_err();
292 assert_eq!(error_code(&e), -32602, "{method}: {e}");
293 }
294
295 // Config options: model picker lists the endpoint's models; bad ids are rejected.
296 let created = agent
297 .ok("session/new", json!({"cwd": work, "mcpServers": []}))
298 .await;
299 let opts = created["configOptions"].as_array().unwrap();
300 assert_eq!(opts[0]["id"], "model");
301 assert_eq!(opts[0]["category"], "model");
302 assert_eq!(opts[0]["currentValue"], MODEL);
303 let values: Vec<&str> = opts[0]["options"]
304 .as_array()
305 .unwrap()
306 .iter()
307 .map(|o| o["value"].as_str().unwrap())
308 .collect();
309 assert!(
310 values.contains(&MODEL) && values.contains(&"other-model"),
311 "{values:?}"
312 );
313 let sid2 = created["sessionId"].as_str().unwrap().to_string();
314 let e = agent
315 .call(
316 "session/set_config_option",
317 json!({"sessionId": sid2, "configId": "model", "value": "bogus"}),
318 )
319 .await
320 .unwrap_err();
321 assert_eq!(error_code(&e), -32602, "{e}");
322 let e = agent
323 .call(
324 "session/set_config_option",
325 json!({"sessionId": sid2, "configId": "temperature", "value": "x"}),
326 )
327 .await
328 .unwrap_err();
329 assert_eq!(error_code(&e), -32602, "{e}");
330 let r = agent
331 .ok(
332 "session/set_config_option",
333 json!({"sessionId": sid2, "configId": "model", "value": "other-model"}),
334 )
335 .await;
336 assert_eq!(r["configOptions"][0]["currentValue"], "other-model");
337 agent.text_prompt(&sid2, "second").await;
338 let last = stats.payloads.lock().unwrap().last().cloned().unwrap();
339 assert_eq!(last["model"], "other-model");
340
341 // Resume rebinds the cwd without replaying anything.
342 agent.notes.clear();
343 agent
344 .ok(
345 "session/resume",
346 json!({"sessionId": sid, "cwd": work2, "mcpServers": []}),
347 )
348 .await;
349 assert!(
350 agent.updates("user_message_chunk").is_empty(),
351 "resume must not replay"
352 );
353 let listed = agent.ok("session/list", json!({"cwd": work2})).await;
354 assert_eq!(listed["sessions"].as_array().unwrap().len(), 1);
355
356 // Close keeps the stored conversation; delete removes it.
357 agent.ok("session/close", json!({"sessionId": sid})).await;
358 let e = agent.call("session/close", json!({"sessionId": sid})).await;
359 assert!(
360 e.is_ok(),
361 "close of a stored-only session is a no-op, not an error"
362 );
363 let all = agent.ok("session/list", json!({})).await;
364 assert_eq!(all["sessions"].as_array().unwrap().len(), 2);
365 agent.ok("session/delete", json!({"sessionId": sid})).await;
366 let all = agent.ok("session/list", json!({})).await;
367 assert_eq!(all["sessions"].as_array().unwrap().len(), 1);
368 assert!(!data.join("sessions").join(format!("{sid}.json")).exists());
369 // Delete is idempotent: a second delete, or one for a session that never existed, succeeds.
370 agent.ok("session/delete", json!({"sessionId": sid})).await;
371 agent
372 .ok("session/delete", json!({"sessionId": "nope"}))
373 .await;
374
375 // A well-formed id with no saved history (a thread from a process that never saved it)
376 // reopens empty under the same id, with a notice, instead of failing to launch.
377 agent.notes.clear();
378 let r = agent
379 .ok(
380 "session/load",
381 json!({"sessionId": "lost-thread", "cwd": work, "mcpServers": []}),
382 )
383 .await;
384 assert_eq!(r["configOptions"][0]["id"], "model");
385 let notice = agent.updates("agent_message_chunk");
386 assert_eq!(notice.len(), 1, "{notice:?}");
387 assert!(
388 notice[0]["content"]["text"]
389 .as_str()
390 .unwrap()
391 .contains("starts fresh"),
392 "{notice:?}"
393 );
394 assert!(notice[0]["messageId"].is_string());
395 agent.text_prompt("lost-thread", "hello again").await;
396 agent.finish().await;
397 }
398
399 pub async fn streaming_reports_message_ids_usage_and_inlines_file_links(t: &Target) {
400 let data = temp_dir("sf-data");
401 let work = temp_dir("sf-work");
402 std::fs::write(
403 work.join("chart.md"),
404 "Verse: Am F C G\nChorus: F G Em Am\nBridge: Dm E\n",
405 )
406 .unwrap();
407 let (url, stats) = mock(vec![Step::Final("Looks like A minor.")]).await;
408 let mut env = t.env(&data, &url);
409 let window = env.var("CONTEXT_WINDOW");
410 env.set(&window, "1000");
411 let mut agent = Raw::spawn(&env);
412 agent.initialize().await;
413 let sid = agent.new_session(&work, json!([])).await;
414 let chart = format!("file://{}?column=1#L2:2", work.join("chart.md").display());
415 agent
416 .prompt(
417 &sid,
418 json!([
419 {"type": "text", "text": "What key is the chorus in?"},
420 {"type": "resource_link", "name": "chart.md", "uri": chart},
421 ]),
422 )
423 .await;
424
425 // The selection link is inlined as just that line.
426 let payload = stats.payloads.lock().unwrap()[0].clone();
427 assert!(payload["stream_options"]["include_usage"] == true);
428 let user = payload["messages"].as_array().unwrap().last().unwrap()["content"]
429 .as_str()
430 .unwrap()
431 .to_string();
432 assert!(user.contains("Chorus: F G Em Am"), "{user}");
433 assert!(
434 !user.contains("Verse:") && !user.contains("Bridge:"),
435 "{user}"
436 );
437
438 // Every chunk of one assistant message carries the same message id.
439 let chunks = agent.updates("agent_message_chunk");
440 assert!(!chunks.is_empty());
441 assert!(
442 chunks.iter().all(|c| c["messageId"].is_string()),
443 "{chunks:?}"
444 );
445 // Usage from the endpoint, sized by <PREFIX>_CONTEXT_WINDOW.
446 let usage = agent.updates("usage_update");
447 assert_eq!(usage.len(), 1, "{usage:?}");
448 assert_eq!(
449 (usage[0]["used"].as_u64(), usage[0]["size"].as_u64()),
450 (Some(150), Some(1000))
451 );
452 agent.finish().await;
453 }
454
455 pub async fn permissions_reject_always_raw_output_and_auto_approve(t: &Target) {
456 let data = temp_dir("sf-data");
457 let work = temp_dir("sf-work");
458 let write = |path: &'static str| Step::ToolCall {
459 name: "write_file",
460 arguments: json!({"path": path, "content": "x"}),
461 };
462 let (url, _) = mock(vec![write("a.txt"), write("b.txt"), Step::Final("done")]).await;
463 let mut agent = Raw::spawn(&t.env(&data, &url));
464 // A client that supports boolean config options gets the auto-approve toggle.
465 let init = agent
466 .ok(
467 "initialize",
468 json!({"protocolVersion": 1, "clientCapabilities": {"session": {"configOptions": {"boolean": {}}}}}),
469 )
470 .await;
471 assert_eq!(init["protocolVersion"], 1);
472 let created = agent
473 .ok("session/new", json!({"cwd": work, "mcpServers": []}))
474 .await;
475 agent.settle().await;
476 let sid = created["sessionId"].as_str().unwrap().to_string();
477 let opts = created["configOptions"].as_array().unwrap();
478 assert_eq!(opts[1]["id"], "auto_approve");
479 assert_eq!(opts[1]["type"], "boolean");
480 assert_eq!(opts[1]["currentValue"], false);
481
482 // "Always reject" is offered, and once chosen the tool is refused without asking again.
483 agent.perm = Perm::RejectAlways;
484 agent.text_prompt(&sid, "write two files").await;
485 assert_eq!(
486 agent.permissions.len(),
487 1,
488 "asked again after reject_always"
489 );
490 let kinds: Vec<&str> = agent.permissions[0]["options"]
491 .as_array()
492 .unwrap()
493 .iter()
494 .map(|o| o["kind"].as_str().unwrap())
495 .collect();
496 assert!(kinds.contains(&"reject_always"), "{kinds:?}");
497 assert!(!work.join("a.txt").exists() && !work.join("b.txt").exists());
498 // Tool calls carry the tool name; updates carry the raw output the model saw.
499 let calls = agent.updates("tool_call");
500 assert!(calls.iter().all(|c| c["name"] == "write_file"), "{calls:?}");
501 let done = agent.updates("tool_call_update");
502 let failed: Vec<&Value> = done.iter().filter(|u| u["status"] == "failed").collect();
503 assert_eq!(failed.len(), 2, "{done:?}");
504 assert_eq!(failed[0]["rawOutput"]["failed"], true);
505 assert!(
506 failed[0]["rawOutput"]["output"]
507 .as_str()
508 .unwrap()
509 .contains("rejected")
510 );
511
512 // Turning auto-approve on is broadcast as a config_option_update and skips the prompts.
513 agent.notes.clear();
514 let r = agent
515 .ok(
516 "session/set_config_option",
517 json!({"sessionId": sid, "configId": "auto_approve", "type": "boolean", "value": true}),
518 )
519 .await;
520 assert_eq!(r["configOptions"][1]["currentValue"], true);
521 let broadcast = agent.updates("config_option_update");
522 assert_eq!(broadcast.len(), 1, "{broadcast:?}");
523 let e = agent
524 .call(
525 "session/set_config_option",
526 json!({"sessionId": sid, "configId": "auto_approve", "value": "yes"}),
527 )
528 .await
529 .unwrap_err();
530 assert_eq!(error_code(&e), -32602, "{e}");
531
532 // A fresh session (no reject_always grant) now writes without asking.
533 let sid2 = agent.new_session(&work, json!([])).await;
534 agent
535 .ok(
536 "session/set_config_option",
537 json!({"sessionId": sid2, "configId": "auto_approve", "type": "boolean", "value": true}),
538 )
539 .await;
540 agent.permissions.clear();
541 agent.text_prompt(&sid2, "write two files").await;
542 assert!(agent.permissions.is_empty(), "{:?}", agent.permissions);
543 assert!(work.join("a.txt").exists() && work.join("b.txt").exists());
544 agent.finish().await;
545 }
546
547 pub async fn auto_approve_is_hidden_from_clients_without_boolean_options(t: &Target) {
548 let data = temp_dir("sf-data");
549 let work = temp_dir("sf-work");
550 let (url, _) = mock(vec![Step::Final("x")]).await;
551 let mut agent = Raw::spawn(&t.env(&data, &url));
552 agent.initialize().await;
553 let created = agent
554 .ok("session/new", json!({"cwd": work, "mcpServers": []}))
555 .await;
556 agent.settle().await;
557 let opts = created["configOptions"].as_array().unwrap();
558 assert_eq!(opts.len(), 1, "{opts:?}");
559 let e = agent
560 .call(
561 "session/set_config_option",
562 json!({"sessionId": created["sessionId"], "configId": "auto_approve", "type": "boolean", "value": true}),
563 )
564 .await
565 .unwrap_err();
566 assert_eq!(error_code(&e), -32602, "{e}");
567 agent.finish().await;
568 }
569
570 pub async fn content_filter_is_a_refusal_and_private_flags_stay_off_the_wire(t: &Target) {
571 let data = temp_dir("sf-data");
572 let work = temp_dir("sf-work");
573 let (url, stats) = mock(vec![
574 Step::ToolCall {
575 name: "read_file",
576 arguments: json!({"path": "missing.txt"}),
577 },
578 Step::ContentFilter,
579 ])
580 .await;
581 let mut agent = Raw::spawn(&t.env(&data, &url));
582 agent.initialize().await;
583 let sid = agent.new_session(&work, json!([])).await;
584 let r = agent.text_prompt(&sid, "read it").await;
585 assert_eq!(r["stopReason"], "refusal");
586 let payloads = stats.payloads.lock().unwrap().clone();
587 let second = payloads.last().unwrap()["messages"]
588 .as_array()
589 .unwrap()
590 .clone();
591 let tool = second.iter().find(|m| m["role"] == "tool").unwrap();
592 assert!(
593 tool["content"].as_str().unwrap().starts_with("Error:"),
594 "{tool}"
595 );
596 assert!(
597 tool.get("x_failed").is_none(),
598 "private field leaked to the model: {tool}"
599 );
600 // The system prompt is rebuilt each turn and never stored with the history.
601 assert_eq!(second[0]["role"], "system");
602 let stored: Value = serde_json::from_slice(
603 &std::fs::read(data.join("sessions").join(format!("{sid}.json"))).unwrap(),
604 )
605 .unwrap();
606 assert!(
607 stored["messages"]
608 .as_array()
609 .unwrap()
610 .iter()
611 .all(|m| m["role"] != "system")
612 );
613 agent.finish().await;
614 }
615
616 pub async fn client_supplied_mcp_server_is_offered_gated_and_streams_progress(t: &Target) {
617 let data = temp_dir("sf-data");
618 let work = temp_dir("sf-work");
619 let pid_file = data.join("stub.pid");
620 let (url, stats) = mock(vec![
621 Step::ToolCall {
622 name: "mcp__stub__echo",
623 arguments: json!({"text": "hi"}),
624 },
625 Step::ToolCall {
626 name: "mcp__stub__slow_progress",
627 arguments: json!({}),
628 },
629 Step::Final("all done"),
630 ])
631 .await;
632 let mut agent = Raw::spawn(&t.env(&data, &url));
633 agent.initialize().await;
634 let sid = agent
635 .new_session(
636 &work,
637 json!([stub_server(&t.mcp_stub, "stub", Some(&pid_file))]),
638 )
639 .await;
640 let r = agent.text_prompt(&sid, "use the stub").await;
641 assert_eq!(r["stopReason"], "end_turn");
642
643 // Offered: the namespaced tools ride along with the built-ins.
644 let payload = stats.payloads.lock().unwrap()[0].clone();
645 let names: Vec<String> = payload["tools"]
646 .as_array()
647 .unwrap()
648 .iter()
649 .map(|t| t["function"]["name"].as_str().unwrap().to_string())
650 .collect();
651 assert!(names.contains(&"mcp__stub__echo".to_string()), "{names:?}");
652 assert_eq!(names.len(), t.base_tool_count + 5, "{names:?}");
653
654 // Gated: only the tool without readOnlyHint asked for permission.
655 assert_eq!(agent.permissions.len(), 1, "{:?}", agent.permissions);
656 let perm = &agent.permissions[0];
657 assert_eq!(perm["toolCall"]["toolCallId"], "call_2");
658
659 // Callable, with the result fed back to the model.
660 let payloads = stats.payloads.lock().unwrap().clone();
661 let tools: Vec<&Value> = payloads[1]["messages"]
662 .as_array()
663 .unwrap()
664 .iter()
665 .filter(|m| m["role"] == "tool")
666 .collect();
667 assert_eq!(tools[0]["content"], "echo: hi");
668
669 // Progress: MCP notifications/progress became tool_call_update content.
670 let progress: Vec<String> = agent
671 .updates("tool_call_update")
672 .iter()
673 .filter(|u| u["toolCallId"] == "call_2")
674 .map(tool_text)
675 .collect();
676 assert!(
677 progress.iter().any(|t| t == "halfway (50%)"),
678 "{progress:?}"
679 );
680 let done = agent
681 .updates("tool_call_update")
682 .into_iter()
683 .rfind(|u| u["toolCallId"] == "call_2")
684 .unwrap();
685 assert_eq!(done["status"], "completed");
686
687 // Children are killed when the session closes.
688 let pid = read_pid(&pid_file);
689 assert!(alive(pid));
690 agent.ok("session/close", json!({"sessionId": sid})).await;
691 assert!(
692 wait_dead(pid).await,
693 "stub server still running after session/close"
694 );
695 agent.finish().await;
696 }
697
698 pub async fn mcp_children_die_when_the_agent_exits(t: &Target) {
699 let data = temp_dir("sf-data");
700 let work = temp_dir("sf-work");
701 let pid_file = data.join("stub.pid");
702 let (url, _) = mock(vec![Step::Final("x")]).await;
703 let mut agent = Raw::spawn(&t.env(&data, &url));
704 agent.initialize().await;
705 agent
706 .new_session(
707 &work,
708 json!([stub_server(&t.mcp_stub, "stub", Some(&pid_file))]),
709 )
710 .await;
711 let pid = read_pid(&pid_file);
712 assert!(alive(pid));
713 agent.finish().await;
714 assert!(wait_dead(pid).await, "stub server outlived the agent");
715 }
716
717 pub async fn broken_mcp_server_is_not_fatal(t: &Target) {
718 let data = temp_dir("sf-data");
719 let work = temp_dir("sf-work");
720 let (url, stats) = mock(vec![Step::Final("fine")]).await;
721 let mut agent = Raw::spawn(&t.env(&data, &url));
722 agent.initialize().await;
723 let sid = agent
724 .new_session(
725 &work,
726 json!([
727 {"name": "ghost", "command": "/definitely/not/here", "args": [], "env": []},
728 {"name": "http", "type": "http", "url": "http://127.0.0.1:1/mcp", "headers": []}
729 ]),
730 )
731 .await;
732 let r = agent.text_prompt(&sid, "hello").await;
733 assert_eq!(r["stopReason"], "end_turn");
734 let payload = stats.payloads.lock().unwrap()[0].clone();
735 assert_eq!(
736 payload["tools"].as_array().unwrap().len(),
737 t.base_tool_count
738 );
739 agent.finish().await;
740 }
741
742 pub async fn cancelling_a_turn_cancels_the_mcp_call(t: &Target) {
743 let data = temp_dir("sf-data");
744 let work = temp_dir("sf-work");
745 let (url, _) = mock(vec![
746 Step::ToolCall {
747 name: "mcp__stub__wait_for_cancel",
748 arguments: json!({}),
749 },
750 Step::Final("should not get here"),
751 ])
752 .await;
753 let mut agent = Raw::spawn(&t.env(&data, &url));
754 agent.initialize().await;
755 let sid = agent
756 .new_session(&work, json!([stub_server(&t.mcp_stub, "stub", None)]))
757 .await;
758 // Fire the prompt without waiting, then cancel once the call is in flight.
759 let id = agent.next_id;
760 agent.next_id += 1;
761 agent
762 .send(
763 json!({"jsonrpc": "2.0", "id": id, "method": "session/prompt",
764 "params": {"sessionId": sid, "prompt": [{"type": "text", "text": "wait"}]}}),
765 )
766 .await;
767 let mut cancelled = false;
768 let stop = loop {
769 let msg = agent.next_message().await;
770 if msg.get("method").is_some() {
771 if msg.get("id").is_some() {
772 agent.serve(&msg).await;
773 // The permission request means the tool call is about to start.
774 tokio::time::sleep(Duration::from_millis(300)).await;
775 agent
776 .notify("session/cancel", json!({"sessionId": sid}))
777 .await;
778 cancelled = true;
779 } else {
780 agent.notes.push(msg);
781 }
782 } else if msg["id"] == json!(id) {
783 break msg["result"]["stopReason"].clone();
784 }
785 };
786 assert!(cancelled, "the call should have asked for permission first");
787 assert_eq!(stop, "cancelled");
788 let last = agent
789 .updates("tool_call_update")
790 .into_iter()
791 .last()
792 .unwrap();
793 assert_eq!(last["status"], "failed");
794 agent.finish().await;
795 }