1use super::fake_prompt_mcp::FakePromptMcp;
2use super::server::{AcpServer, ServerRunError};
3use super::session::actor::SessionActorInit;
4use super::session::agent_key::AgentKey;
5use super::session::agents::SessionAgents;
6use super::session::config::SessionConfigState;
7use super::session::error::SessionError;
8use super::session::model::{Modes, ValidatedMode};
9use super::session::runtime::{AgentRuntime, RuntimeFactory};
10use super::state::{AcpState, AcpStateConfig};
11use crate::error::CliError;
12use crate::resolve::InitialSessionSelection;
13use crate::settings_args::SettingsSourceArgs;
14use crate::workspace::WorkspaceManager;
15use crate::workspace::testing::StdCopyCloner;
16use acp_utils::notifications::{AuthMethodsUpdatedParams, McpNotification};
17use acp_utils::testing::{TestPeer, duplex_pair, initialize_request};
18use aether_auth::OAuthCredentialStorage;
19use aether_core::agent_spec::{AgentSpec, AgentSpecExposure};
20use aether_core::core::{AgentBuilder, AgentHandle, Prompt};
21use aether_core::events::{AgentEvent, Command, MessageEvent, TurnEvent, TurnOutcome};
22use aether_core::mcp::{ServerFactory, mcp};
23use aether_project::AgentCatalog;
24use aether_sessions::SessionStore;
25use aether_sessions::{SessionControlEvent, SessionEvent, SessionMeta, UserEvent, last_agent_from_events};
26use agent_client_protocol::schema::v2::{InitializeResponse, SessionId, SessionUpdate, StateUpdate, StopReason};
27use agent_client_protocol::{Agent, Client, ConnectionTo, on_receive_notification};
28use futures::FutureExt;
29use llm::testing::FakeLlmProvider;
30use llm::{ChatMessage, Context, LlmResponse, SessionUsageEvent, StreamingModelProvider};
31use llm::{MessageId, ProviderConnectionOverrides};
32use mcp_utils::client::{InMemoryServerSpec, McpServer, McpTransport, ToolExposure};
33use std::collections::HashMap;
34use std::future::Future;
35use std::net::SocketAddr;
36use std::path::PathBuf;
37use std::sync::{Arc, Mutex};
38use tokio::sync::{mpsc, oneshot, watch};
39use tokio::task::{JoinHandle, JoinSet, LocalSet};
40use tokio_util::sync::CancellationToken;
41
42const PLANNER_REPLY: &str = "planner reply";
43const CODER_REPLY: &str = "coder reply";
44
45pub struct AcpTestHarness {
46 pub client_cx: ConnectionTo<Agent>,
47 pub peer: TestPeer,
48 pub initialize_response: InitializeResponse,
49 connection: Option<HarnessConnectionTasks>,
50 pub auth_updates: mpsc::UnboundedReceiver<acp_utils::notifications::AuthMethodsUpdatedParams>,
51 resume_agent: FakeAcpAgent,
52 runtime_control: Arc<Mutex<FakeRuntimeControl>>,
53 pub oauth_store: Arc<aether_auth::FakeOAuthCredentialStore>,
54 agent_cx: ConnectionTo<Client>,
55 state: Arc<AcpState>,
56 session_store: Arc<SessionStore>,
57 _tmp: tempfile::TempDir,
58}
59
60pub struct AcpWebSocketTestServer {
62 pub address: SocketAddr,
63 detached: watch::Receiver<u64>,
64 shutdown: Option<oneshot::Sender<()>>,
65 task: JoinHandle<Result<(), ServerRunError>>,
66}
67
68impl AcpWebSocketTestServer {
69 pub fn url(&self) -> String {
70 format!("ws://{}", self.address)
71 }
72
73 pub fn subscribe(&self) -> watch::Receiver<u64> {
74 self.detached.clone()
75 }
76
77 pub async fn shutdown(mut self) {
79 let _ = self.shutdown.take().expect("server is running").send(());
80 (&mut self.task).await.expect("listener task joins").expect("listener shuts down");
81 }
82
83 pub async fn abort(mut self) {
85 self.task.abort();
86 assert!((&mut self.task).await.expect_err("listener task is aborted").is_cancelled());
87 }
88}
89
90impl Drop for AcpWebSocketTestServer {
91 fn drop(&mut self) {
92 self.task.abort();
93 }
94}
95
96pub struct FakeAgentSwitchingSession {
97 session_id: SessionId,
98 planner: FakeAcpAgent,
99 coder: FakeAcpAgent,
100}
101
102#[derive(Clone)]
103pub struct FakeAcpAgent {
104 name: String,
105 captured_contexts: Arc<Mutex<Vec<Context>>>,
106}
107
108pub struct PendingRuntime {
109 started: oneshot::Receiver<()>,
110 release: oneshot::Sender<bool>,
111}
112
113impl PendingRuntime {
114 pub async fn wait_until_started(&mut self) {
115 (&mut self.started).await.expect("runtime startup reached");
116 }
117
118 pub fn finish(self, succeed: bool) {
119 self.release.send(succeed).expect("runtime startup is waiting");
120 }
121}
122
123impl AcpTestHarness {
124 pub fn pause_next_runtime(&self) -> PendingRuntime {
125 let (started, observed) = oneshot::channel();
126 let (release, proceed) = oneshot::channel();
127 self.runtime_control.lock().unwrap().pending = Some((started, proceed));
128 PendingRuntime { started: observed, release }
129 }
130
131 pub fn pause_prompt_expansion(&self) -> (Arc<tokio::sync::Notify>, Arc<tokio::sync::Notify>) {
132 let gate = (Arc::new(tokio::sync::Notify::new()), Arc::new(tokio::sync::Notify::new()));
133 self.runtime_control.lock().unwrap().prompt_gate = Some(gate.clone());
134 gate
135 }
136
137 pub fn elicit_during_prompt_expansion(&self) -> mpsc::UnboundedReceiver<rmcp::model::ElicitResult> {
138 let (sender, receiver) = mpsc::unbounded_channel();
139 self.runtime_control.lock().unwrap().elicitation_results = Some(sender);
140 receiver
141 }
142
143 pub fn live_runtime_count(&self) -> usize {
144 self.runtime_control.lock().unwrap().agents.iter().filter(|sender| !sender.is_closed()).count()
145 }
146
147 pub async fn disconnect(&mut self) {
148 if let Some(connection) = self.connection.take() {
149 connection.shutdown().await;
150 }
151 }
152
153 pub async fn reconnect(&mut self) {
155 assert!(self.connection.is_none(), "disconnect the current client before reconnecting");
156 assert!(!self.state.stop_token().is_cancelled(), "host is running");
157 let connection = connect_client(self.state.clone()).await;
158 self.client_cx = connection.client_cx;
159 self.agent_cx = connection.agent_cx;
160 self.peer = connection.peer;
161 self.initialize_response = connection.initialize_response;
162 self.auth_updates = connection.auth_updates;
163 self.connection = Some(connection.tasks);
164 }
165
166 pub async fn serve_websocket(&mut self) -> AcpWebSocketTestServer {
168 self.disconnect().await;
169 self.listen_websocket().await
170 }
171
172 pub async fn listen_websocket(&self) -> AcpWebSocketTestServer {
174 assert!(!self.state.stop_token().is_cancelled(), "host is running");
175 let server =
176 AcpServer::bind("127.0.0.1:0".parse().unwrap(), self.state.clone()).await.expect("bind test server");
177 let address = server.local_addr().expect("listener address");
178 let detached = self.state.client_slot().subscribe();
179 let (shutdown, stopped) = oneshot::channel();
180 let task = tokio::spawn(server.run_until(async move {
181 let _ = stopped.await;
182 Ok(())
183 }));
184 AcpWebSocketTestServer { address, detached, shutdown: Some(shutdown), task }
185 }
186
187 pub async fn shutdown(&mut self) {
189 self.disconnect().await;
190 self.state.shutdown_all().await;
191 }
192
193 pub fn stored_events(&self, session_id: &SessionId) -> Vec<SessionEvent> {
194 self.session_store.load(session_id.0.as_ref()).expect("stored session loads").1
195 }
196
197 pub async fn run<F, Fut>(body: F)
201 where
202 F: FnOnce(Self) -> Fut,
203 Fut: Future<Output = ()>,
204 {
205 LocalSet::new().run_until(Box::pin(async move { body(Self::start().await).await })).await;
206 }
207
208 pub async fn start() -> Self {
209 let tmp = tempfile::tempdir().expect("tempdir for session store");
210 let session_store = Arc::new(SessionStore::from_path(tmp.path().to_path_buf()));
211 let workspace_manager = Arc::new(WorkspaceManager::from_registry_path_with_cloner(
212 tmp.path().join("workspaces.json"),
213 Arc::new(StdCopyCloner),
214 ));
215 let (resume_def, resume_agent) = fake_agent("Resume", "resume-mcp", "resume", "resumed reply");
216 let mut resume_agents = HashMap::new();
217 resume_agents.insert(resume_def.spec.name.clone(), resume_def);
218 let runtime_control = Arc::new(Mutex::new(FakeRuntimeControl::default()));
219 let runtime_factory = Arc::new(FakeRuntimeFactory {
220 cwd: PathBuf::from("/tmp"),
221 agents: resume_agents,
222 control: runtime_control.clone(),
223 });
224 let oauth_store = Arc::new(aether_auth::FakeOAuthCredentialStore::new());
225 let state = Arc::new(AcpState::with_login(
226 AcpStateConfig {
227 session_store: session_store.clone(),
228 workspace_manager,
229 oauth_credential_store: oauth_store.clone(),
230 initial_selection: InitialSessionSelection::default(),
231 settings_source: SettingsSourceArgs::default(),
232 provider_connections: ProviderConnectionOverrides::default(),
233 telemetry: None,
234 runtime_factory: Some(runtime_factory),
235 cwd: PathBuf::from("/tmp"),
236 },
237 Arc::new(FakeProviderLogin),
238 ));
239
240 let connection = connect_client(state.clone()).await;
241 Self {
242 client_cx: connection.client_cx,
243 peer: connection.peer,
244 initialize_response: connection.initialize_response,
245 connection: Some(connection.tasks),
246 auth_updates: connection.auth_updates,
247 resume_agent,
248 runtime_control,
249 oauth_store,
250 agent_cx: connection.agent_cx,
251 state,
252 session_store,
253 _tmp: tmp,
254 }
255 }
256
257 pub fn resume_agent(&self) -> &FakeAcpAgent {
258 &self.resume_agent
259 }
260
261 pub async fn insert_agent_switching_session(&self) -> FakeAgentSwitchingSession {
262 self.insert_switching_session(
263 SessionId::new("agent-switching-session"),
264 Vec::new(),
265 Some("Planner".to_string()),
266 false,
267 )
268 .await
269 }
270
271 pub async fn insert_agent_switching_session_with_serverless_coder(&self) -> FakeAgentSwitchingSession {
272 self.insert_switching_session(
273 SessionId::new("agent-switching-serverless-session"),
274 Vec::new(),
275 Some("Planner".to_string()),
276 true,
277 )
278 .await
279 }
280
281 pub async fn insert_loaded_agent_switching_session(&self, session_id: &str) -> FakeAgentSwitchingSession {
282 let events = self.session_store.load(session_id).map(|(_, events)| events).unwrap_or_default();
283 let selected_mode = last_agent_from_events(Some("Planner".to_string()), &events);
284 self.insert_switching_session(SessionId::new(session_id), events, selected_mode, false).await
285 }
286
287 pub async fn expect_idle(&mut self, session_id: &SessionId, expected: StopReason) {
288 loop {
289 let notification = self.peer.next_session_notification().await;
290 if notification.session_id == *session_id
291 && let SessionUpdate::StateUpdate(StateUpdate::Idle(idle)) = notification.update
292 {
293 assert_eq!(idle.stop_reason, Some(expected));
294 return;
295 }
296 }
297 }
298
299 pub async fn expect_mcp_server_status(&mut self, expected: &[&str]) {
300 assert_server_status(self.peer.next_mcp_notification().await, expected);
301 }
302
303 pub async fn expect_mcp_server_status_exact(&mut self, expected: &[&str]) {
304 assert_server_status_exact(self.peer.next_mcp_notification().await, expected);
305 }
306
307 pub async fn expect_available_commands(&mut self, expected: &[&str], unexpected: &[&str]) {
308 loop {
309 let update = self.peer.next_session_notification().await.update;
310 if matches!(update, SessionUpdate::AvailableCommandsUpdate(_)) {
311 assert_available_commands(update, expected, unexpected);
312 return;
313 }
314 }
315 }
316
317 pub fn append_agent_switch(&self, session_id: &str, from: Option<&str>, to: Option<&str>) {
318 self.append_stored_event(
319 session_id,
320 &SessionEvent::Control(SessionControlEvent::AgentSwitched {
321 from: from.map(str::to_string),
322 to: to.map(str::to_string),
323 }),
324 );
325 }
326
327 pub async fn insert_stub_session(
333 &self,
334 agent_tx: mpsc::Sender<Command>,
335 agent_rx: mpsc::Receiver<AgentEvent>,
336 agent_handle: AgentHandle,
337 id: SessionId,
338 model: &str,
339 ) {
340 let model_spec: llm::catalog::LlmModel = "anthropic:claude-sonnet-4-5".parse().expect("test model parses");
341 let mut specs = SessionAgents::new(AgentCatalog::empty(PathBuf::from("/tmp")));
342 specs.set_default(AgentSpec::bare(&model_spec, None, Vec::new()));
343 self.runtime_control.lock().unwrap().agents.push(agent_tx.clone());
344 let factory = Arc::new(StubRuntimeFactory {
345 cwd: PathBuf::from("/tmp"),
346 agent_parts: Mutex::new(Some(StubAgentParts { tx: agent_tx, rx: agent_rx, handle: agent_handle })),
347 });
348
349 self.state
350 .register_session(SessionActorInit {
351 cwd: PathBuf::from("/tmp"),
352 mcp_servers: Vec::new(),
353 session_id: id.clone(),
354 connection: self.agent_cx.clone(),
355 repository: self.session_store.clone(),
356 oauth_credential_store: self.oauth_store.clone(),
357 active_agent: AgentKey::Default,
358 specs,
359 runtime_factory: factory,
360 transcript: Vec::new(),
361 replay: false,
362 modes: Modes::default(),
363 config: SessionConfigState::with_selection(model.to_string(), None, None),
364 })
365 .await;
366 }
367
368 pub fn append_stored_session(&self, session_id: &str, created_at: &str) {
369 self.append_stored_session_in(session_id, created_at, std::path::Path::new("/tmp"));
370 }
371
372 pub fn append_stored_session_in(&self, session_id: &str, created_at: &str, cwd: &std::path::Path) {
373 let meta = SessionMeta {
374 session_id: session_id.to_string(),
375 cwd: cwd.to_path_buf(),
376 model: "anthropic:claude-sonnet-4-5".to_string(),
377 selected_mode: None,
378 created_at: created_at.to_string(),
379 };
380
381 self.session_store.append_meta(session_id, &meta).expect("stored session meta appends");
382 }
383
384 pub fn append_stored_prompt(&self, session_id: &str, prompt: &str) {
385 self.append_stored_event(
386 session_id,
387 &SessionEvent::User(UserEvent::Message {
388 message_id: llm::MessageId::new(),
389 content: vec![llm::ContentBlock::text(prompt)],
390 display_content: None,
391 }),
392 );
393 }
394
395 pub fn append_stored_user_blocks(&self, session_id: &str, blocks: Vec<llm::ContentBlock>) {
396 self.append_stored_event(
397 session_id,
398 &SessionEvent::User(UserEvent::Message {
399 message_id: llm::MessageId::new(),
400 content: blocks,
401 display_content: None,
402 }),
403 );
404 }
405
406 pub fn append_stored_agent_turn(&self, session_id: &str, text: &str) {
407 self.append_stored_agent_text(session_id, text);
408 self.append_stored_event(
409 session_id,
410 &SessionEvent::Agent(AgentEvent::Turn(TurnEvent::Ended { outcome: TurnOutcome::Completed })),
411 );
412 }
413
414 pub fn append_stored_agent_text(&self, session_id: &str, text: &str) {
415 self.append_stored_event(
416 session_id,
417 &SessionEvent::Agent(AgentEvent::Message(MessageEvent::Text {
418 message_id: MessageId::new(),
419 chunk: text.to_string(),
420 is_complete: true,
421 })),
422 );
423 }
424
425 async fn insert_switching_session(
426 &self,
427 acp_session_id: SessionId,
428 events: Vec<SessionEvent>,
429 selected_mode: Option<String>,
430 serverless_coder: bool,
431 ) -> FakeAgentSwitchingSession {
432 let (planner_def, planner) = fake_agent("Planner", "planner-mcp", "plan", PLANNER_REPLY);
433 let (mut coder_def, coder) = fake_agent("Coder", "coder-mcp", "edit", CODER_REPLY);
434 if serverless_coder {
435 coder_def.mcp = None;
436 }
437
438 let mut catalog_specs = Vec::new();
439 let mut agents = HashMap::new();
440 for def in [planner_def, coder_def] {
441 catalog_specs.push(def.spec.clone());
442 agents.insert(def.spec.name.clone(), def);
443 }
444 let specs = SessionAgents::new(AgentCatalog::new(PathBuf::from("/tmp"), catalog_specs, None));
445
446 let factory =
447 Arc::new(FakeRuntimeFactory { cwd: PathBuf::from("/tmp"), agents, control: self.runtime_control.clone() });
448 let initial_agent = selected_mode.clone().unwrap_or_else(|| "Planner".to_string());
449
450 self.state
451 .register_session(SessionActorInit {
452 cwd: PathBuf::from("/tmp"),
453 mcp_servers: Vec::new(),
454 session_id: acp_session_id.clone(),
455 connection: self.agent_cx.clone(),
456 repository: self.session_store.clone(),
457 oauth_credential_store: self.oauth_store.clone(),
458 active_agent: AgentKey::Named(initial_agent),
459 specs,
460 runtime_factory: factory,
461 transcript: events,
462 replay: false,
463 modes: switching_modes(),
464 config: SessionConfigState::with_selection(
465 "anthropic:claude-sonnet-4-5".to_string(),
466 selected_mode,
467 None,
468 ),
469 })
470 .await;
471 FakeAgentSwitchingSession { session_id: acp_session_id, planner, coder }
472 }
473
474 pub fn append_stored_event(&self, session_id: &str, event: &SessionEvent) {
475 self.session_store.append_event(session_id, event).expect("stored session event appends");
476 }
477}
478
479impl FakeAgentSwitchingSession {
480 pub fn session_id(&self) -> &SessionId {
481 &self.session_id
482 }
483
484 pub fn planner(&self) -> &FakeAcpAgent {
485 &self.planner
486 }
487
488 pub fn coder(&self) -> &FakeAcpAgent {
489 &self.coder
490 }
491}
492
493impl FakeAcpAgent {
494 pub fn assert_saw(&self, expected: &[&str]) {
497 let seen = self.latest_conversation();
498 for text in expected {
499 assert!(seen.iter().any(|m| m == text), "{} should have seen {text:?}; saw {seen:?}", self.name);
500 }
501 }
502
503 pub fn assert_saw_exactly(&self, expected: &[&str]) {
507 let seen = self.latest_conversation();
508 let expected: Vec<String> = expected.iter().map(|t| (*t).to_string()).collect();
509 assert_eq!(seen, expected, "{} conversation mismatch", self.name);
510 }
511
512 pub fn assert_saw_user_content(&self, expected: &[llm::ContentBlock]) {
513 let contexts = self.captured_contexts.lock().expect("captured contexts lock is healthy");
514 let latest = contexts.last().expect("agent should have run a turn");
515 assert!(
516 latest
517 .messages()
518 .iter()
519 .any(|message| { matches!(message, ChatMessage::User { content, .. } if content == expected) }),
520 "{} should have seen user content {expected:?}",
521 self.name
522 );
523 }
524
525 pub fn assert_never_ran(&self) {
527 let contexts = self.captured_contexts.lock().expect("captured contexts lock is healthy");
528 assert!(contexts.is_empty(), "{} should not have run; captured {} context(s)", self.name, contexts.len());
529 }
530
531 fn latest_conversation(&self) -> Vec<String> {
532 let contexts = self.captured_contexts.lock().expect("captured contexts lock is healthy");
533 let latest = contexts.last().unwrap_or_else(|| panic!("{} should have run a turn", self.name));
534 conversation_texts(latest)
535 }
536}
537
538struct HarnessConnection {
539 client_cx: ConnectionTo<Agent>,
540 agent_cx: ConnectionTo<Client>,
541 peer: TestPeer,
542 initialize_response: InitializeResponse,
543 auth_updates: mpsc::UnboundedReceiver<acp_utils::notifications::AuthMethodsUpdatedParams>,
544 tasks: HarnessConnectionTasks,
545}
546
547struct HarnessConnectionTasks {
548 stop: CancellationToken,
549 tasks: JoinSet<Result<(), agent_client_protocol::Error>>,
550}
551
552impl HarnessConnectionTasks {
553 async fn shutdown(mut self) {
554 self.stop.cancel();
555 while let Some(result) = self.tasks.join_next().await {
556 result.expect("connection task joins").expect("connection cleanup completes");
557 }
558 }
559}
560
561async fn connect_client(state: Arc<AcpState>) -> HarnessConnection {
562 let (peer, client_builder) = TestPeer::new();
563 let (auth_tx, auth_updates) = mpsc::unbounded_channel();
564 let client_builder = client_builder.on_receive_notification(
565 async move |notification: AuthMethodsUpdatedParams, _cx| {
566 let _ = auth_tx.send(notification);
567 Ok(())
568 },
569 on_receive_notification!(),
570 );
571 let (agent_transport, client_transport) = duplex_pair();
572 let (send_agent_connection, agent_ready) = oneshot::channel();
573 let (send_client_connection, client_ready) = oneshot::channel();
574 let stop = state.stop_token().child_token();
575 let mut tasks = JoinSet::new();
576 let agent_stop = stop.clone();
577 tasks.spawn_local(async move { state.serve_with(agent_transport, agent_stop, send_agent_connection).await });
578 tasks.spawn_local(async move {
579 client_builder
580 .with_runner(acp_utils::testing::CaptureConnection(send_client_connection))
581 .connect_to(client_transport)
582 .await
583 });
584 let agent_cx = agent_ready.await.expect("agent connection");
585 let client_cx = client_ready.await.expect("client connection");
586 let initialize_response =
587 client_cx.send_request(initialize_request()).block_task().await.expect("initialize harness");
588 HarnessConnection {
589 client_cx,
590 agent_cx,
591 peer,
592 initialize_response,
593 auth_updates,
594 tasks: HarnessConnectionTasks { stop, tasks },
595 }
596}
597
598struct FakeRuntimeFactory {
602 cwd: PathBuf,
603 agents: HashMap<String, FakeAgentDef>,
604 control: Arc<Mutex<FakeRuntimeControl>>,
605}
606
607#[derive(Default)]
608struct FakeRuntimeControl {
609 agents: Vec<mpsc::Sender<Command>>,
610 pending: Option<(oneshot::Sender<()>, oneshot::Receiver<bool>)>,
611 prompt_gate: Option<(Arc<tokio::sync::Notify>, Arc<tokio::sync::Notify>)>,
612 elicitation_results: Option<mpsc::UnboundedSender<rmcp::model::ElicitResult>>,
613}
614
615struct FakeAgentDef {
616 spec: AgentSpec,
617 provider: Arc<dyn StreamingModelProvider>,
618 mcp: Option<(String, String)>,
619}
620
621#[async_trait::async_trait]
622impl RuntimeFactory for FakeRuntimeFactory {
623 async fn spawn(
624 &self,
625 _agent: AgentKey,
626 spec: &AgentSpec,
627 initial_messages: Vec<ChatMessage>,
628 usage_seed: Option<SessionUsageEvent>,
629 ) -> Result<AgentRuntime, SessionError> {
630 let pending = self.control.lock().unwrap().pending.take();
631 if let Some((started, proceed)) = pending {
632 let _ = started.send(());
633 if !proceed.await.unwrap_or(false) {
634 return Err(SessionError::AgentNotFound("injected startup failure".to_string()));
635 }
636 }
637 let def = self
638 .agents
639 .get(&spec.name)
640 .or_else(|| self.agents.values().next())
641 .ok_or_else(|| SessionError::AgentNotFound(spec.name.clone()))?;
642 let provider = def.provider.clone();
643
644 let mut mcp_builder = mcp(&self.cwd).with_tool_filter(spec.tools.clone());
645 if let Some((server_name, prompt_name)) = &def.mcp {
646 let factory_name = server_name.clone();
647 let prompt_name = prompt_name.clone();
648 let gate = self.control.lock().unwrap().prompt_gate.clone();
649 let elicitation_results = self.control.lock().unwrap().elicitation_results.clone();
650 let factory: ServerFactory = Box::new(move |_spec, _services| {
651 let prompt_name = prompt_name.clone();
652 let gate = gate.clone();
653 let elicitation_results = elicitation_results.clone();
654 async move {
655 FakePromptMcp::new(&prompt_name).with_gate(gate).with_elicitation(elicitation_results).into_dyn()
656 }
657 .boxed()
658 });
659 mcp_builder = mcp_builder.register_in_memory_server(factory_name.clone(), factory).with_servers(vec![
660 McpServer::new(
661 server_name.clone(),
662 McpTransport::InMemory {
663 spec: InMemoryServerSpec { factory: factory_name, args: Vec::new(), input: None },
664 },
665 ToolExposure::ModelVisible,
666 ),
667 ]);
668 }
669 let mut spawn =
670 mcp_builder.spawn().await.map_err(|e| SessionError::Build(CliError::McpError(e.to_string())))?;
671 spawn.block_until_ready().await.ok_or(SessionError::McpStartupStopped)?;
672 let mcp_handle = spawn.handle().clone();
673 let mut builder = AgentBuilder::new(provider).max_auto_continues(0);
674 if let Some(last) = &usage_seed {
675 builder = builder.resume_usage(last);
676 }
677 for prompt in &spec.prompts {
678 builder = builder.system_prompt(prompt.clone());
679 }
680 let (agent_tx, agent_rx, agent_handle) = builder
681 .tools(mcp_handle, Vec::new())
682 .messages(initial_messages)
683 .spawn()
684 .await
685 .map_err(|e| SessionError::Build(CliError::AgentError(e.to_string())))?;
686 self.control.lock().unwrap().agents.push(agent_tx.clone());
687 let (mcp_runtime, event_rx) = spawn.connect_agent(agent_tx.clone()).await.split();
688
689 Ok(AgentRuntime::new(agent_tx, agent_rx, Some(agent_handle), event_rx, mcp_runtime))
690 }
691}
692
693struct StubRuntimeFactory {
694 cwd: PathBuf,
695 agent_parts: Mutex<Option<StubAgentParts>>,
696}
697
698struct StubAgentParts {
699 tx: mpsc::Sender<Command>,
700 rx: mpsc::Receiver<AgentEvent>,
701 handle: AgentHandle,
702}
703
704#[async_trait::async_trait]
705impl RuntimeFactory for StubRuntimeFactory {
706 async fn spawn(
707 &self,
708 _agent: AgentKey,
709 _spec: &AgentSpec,
710 _initial_messages: Vec<ChatMessage>,
711 _usage_seed: Option<SessionUsageEvent>,
712 ) -> Result<AgentRuntime, SessionError> {
713 let parts = self
714 .agent_parts
715 .lock()
716 .expect("stub agent parts lock is healthy")
717 .take()
718 .expect("stub runtime spawned more than once");
719
720 let mut spawn =
721 mcp(&self.cwd).spawn().await.map_err(|e| SessionError::Build(CliError::McpError(e.to_string())))?;
722 spawn.block_until_ready().await.ok_or(SessionError::McpStartupStopped)?;
723 let (mcp_runtime, event_rx) = spawn.connect_agent(parts.tx.clone()).await.split();
724
725 Ok(AgentRuntime::new(parts.tx, parts.rx, Some(parts.handle), event_rx, mcp_runtime))
726 }
727}
728
729fn fake_agent(name: &str, server_name: &str, prompt_name: &str, reply: &str) -> (FakeAgentDef, FakeAcpAgent) {
730 const TURNS_BEFORE_AND_AFTER_REATTACH: usize = 2;
731 let provider = FakeLlmProvider::new(vec![
732 vec![LlmResponse::Start, LlmResponse::text(reply), LlmResponse::done()];
733 TURNS_BEFORE_AND_AFTER_REATTACH
734 ])
735 .with_display_name(name);
736 let captured_contexts = provider.captured_contexts();
737 let def = FakeAgentDef {
738 spec: fake_agent_spec(name),
739 provider: Arc::new(provider),
740 mcp: Some((server_name.to_string(), prompt_name.to_string())),
741 };
742 let observer = FakeAcpAgent { name: name.to_string(), captured_contexts };
743 (def, observer)
744}
745
746struct FakeProviderLogin;
747
748#[async_trait::async_trait]
749impl super::state::ProviderLogin for FakeProviderLogin {
750 async fn login(&self, store: &dyn OAuthCredentialStorage) -> Result<(), llm::LlmError> {
751 store
752 .save("codex", serde_json::json!({"access_token": "fake-access", "refresh_token": "fake-refresh"}))
753 .await?;
754 Ok(())
755 }
756}
757
758fn switching_modes() -> Modes {
759 Modes::new(vec![
760 ValidatedMode {
761 name: "Planner".to_string(),
762 model: "anthropic:claude-sonnet-4-5".to_string(),
763 reasoning_effort: None,
764 },
765 ValidatedMode {
766 name: "Coder".to_string(),
767 model: "deepseek:deepseek-v4-flash".to_string(),
768 reasoning_effort: None,
769 },
770 ])
771}
772
773fn fake_agent_spec(name: &str) -> AgentSpec {
774 let model: llm::catalog::LlmModel = "anthropic:claude-sonnet-4-5".parse().expect("test model parses");
775 let mut spec = AgentSpec::bare(&model, None, vec![Prompt::text(&format!("{name} system prompt"))]);
776 spec.name = name.to_string();
777 spec.description = format!("{name} test agent");
778 spec.exposure = AgentSpecExposure::user_only();
779 spec
780}
781
782fn assert_available_commands(update: SessionUpdate, expected: &[&str], unexpected: &[&str]) {
783 let SessionUpdate::AvailableCommandsUpdate(commands) = update else {
784 panic!("expected available commands update");
785 };
786 let names = commands.available_commands.iter().map(|command| command.name.as_str()).collect::<Vec<_>>();
787 for name in expected {
788 assert!(names.contains(name), "expected command /{name} in {names:?}");
789 }
790 for name in unexpected {
791 assert!(!names.contains(name), "did not expect command /{name} in {names:?}");
792 }
793}
794
795fn assert_server_status(notification: McpNotification, expected: &[&str]) {
796 let McpNotification::ServerStatus { servers } = notification;
797 let names = servers.iter().map(|server| server.name.as_str()).collect::<Vec<_>>();
798 for server_name in expected {
799 assert!(names.contains(server_name), "expected server {server_name} in {names:?}");
800 }
801}
802
803fn assert_server_status_exact(notification: McpNotification, expected: &[&str]) {
804 let McpNotification::ServerStatus { servers } = notification;
805 let names = servers.iter().map(|server| server.name.as_str()).collect::<Vec<_>>();
806 assert_eq!(names, expected);
807}
808
809fn conversation_texts(context: &Context) -> Vec<String> {
810 context
811 .messages()
812 .iter()
813 .filter_map(|message| match message {
814 ChatMessage::User { content, .. } => llm::ContentBlock::first_text(content).map(str::to_string),
815 ChatMessage::Assistant { content, .. } if !content.is_empty() => Some(content.clone()),
816 _ => None,
817 })
818 .collect()
819}