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