1use super::agent::acp_agent_builder;
2use super::agent_key::AgentKey;
3use super::agent_runtime::{AgentRuntime, RuntimeEvent, RuntimeFactory};
4use super::error::SessionError;
5use super::fake_prompt_mcp::FakePromptMcp;
6use super::model_config::{Modes, ValidatedMode};
7use super::session_actor::{SessionActor, SessionActorInit};
8use super::session_config_state::SessionConfigState;
9use super::session_factory::InitialSessionSelection;
10use super::state::{AcpState, AcpStateConfig};
11use crate::acp::session_store::{SessionMeta, SessionStore};
12use crate::error::CliError;
13use crate::settings_args::SettingsSourceArgs;
14use crate::workspace::WorkspaceManager;
15use crate::workspace::testing::StdCopyCloner;
16use acp_utils::notifications::McpNotification;
17use acp_utils::testing::{TestPeer, duplex_pair};
18use aether_auth::OAuthCredentialStorage;
19use aether_core::agent_spec::{AgentSpec, AgentSpecExposure};
20use aether_core::context::ext::{SessionControlEvent, SessionEvent, UserEvent, last_agent_from_events};
21use aether_core::core::{AgentBuilder, AgentHandle, Prompt};
22use aether_core::events::{AgentMessage, Command};
23use aether_core::mcp::McpSpawnResult;
24use aether_core::mcp::mcp;
25use agent_client_protocol::schema::{SessionId, SessionUpdate};
26use agent_client_protocol::{Agent, Client, ConnectionTo};
27use llm::ProviderConnectionOverrides;
28use llm::testing::FakeLlmProvider;
29use llm::{ChatMessage, Context, LlmResponse, StreamingModelProvider};
30use mcp_utils::client::{McpServer, McpTransport};
31use std::collections::HashMap;
32use std::path::PathBuf;
33use std::sync::{Arc, Mutex};
34use tokio::sync::{mpsc, oneshot};
35use tokio::task::spawn_local;
36
37const PLANNER_REPLY: &str = "planner reply";
38const CODER_REPLY: &str = "coder reply";
39
40pub struct AcpTestHarness {
46 pub client_cx: ConnectionTo<Agent>,
47 pub peer: TestPeer,
48 agent_cx: ConnectionTo<Client>,
49 state: Arc<AcpState>,
50 session_store: Arc<SessionStore>,
51 _tmp: tempfile::TempDir,
52}
53
54pub struct FakeAgentSwitchingSession {
55 session_id: SessionId,
56 planner: FakeAcpAgent,
57 coder: FakeAcpAgent,
58}
59
60#[derive(Clone)]
61pub struct FakeAcpAgent {
62 name: String,
63 captured_contexts: Arc<Mutex<Vec<Context>>>,
64}
65
66impl AcpTestHarness {
67 pub async fn start() -> Self {
68 let tmp = tempfile::tempdir().expect("tempdir for session store");
69 let session_store = Arc::new(SessionStore::from_path(tmp.path().to_path_buf()));
70 let workspace_manager = Arc::new(WorkspaceManager::from_registry_path_with_cloner(
71 tmp.path().join("workspaces.json"),
72 Arc::new(StdCopyCloner),
73 ));
74 let state = Arc::new(AcpState::new(AcpStateConfig {
75 session_store: session_store.clone(),
76 workspace_manager,
77 oauth_credential_store: fake_oauth_store(),
78 initial_selection: InitialSessionSelection::default(),
79 settings_source: SettingsSourceArgs::default(),
80 provider_connections: ProviderConnectionOverrides::default(),
81 }));
82
83 let (peer, client_builder) = TestPeer::new();
84 let (agent_transport, client_transport) = duplex_pair();
85 let (agent_cx_tx, agent_cx_rx) = oneshot::channel::<ConnectionTo<Client>>();
86 let (client_cx_tx, client_cx_rx) = oneshot::channel::<ConnectionTo<Agent>>();
87
88 let server_state = state.clone();
89 spawn_local(async move {
90 let _ = acp_agent_builder(server_state)
91 .connect_with(agent_transport, async move |cx: ConnectionTo<Client>| {
92 let _ = agent_cx_tx.send(cx);
93 std::future::pending::<()>().await;
94 Ok(())
95 })
96 .await;
97 });
98
99 spawn_local(async move {
100 let _ = client_builder
101 .connect_with(client_transport, async move |cx: ConnectionTo<Agent>| {
102 let _ = client_cx_tx.send(cx);
103 std::future::pending::<()>().await;
104 Ok(())
105 })
106 .await;
107 });
108
109 let agent_cx = agent_cx_rx.await.expect("agent side connect_with produced a ConnectionTo");
110 let client_cx = client_cx_rx.await.expect("client side connect_with produced a ConnectionTo");
111 Self { client_cx, peer, agent_cx, state, session_store, _tmp: tmp }
112 }
113
114 pub async fn insert_agent_switching_session(&self) -> FakeAgentSwitchingSession {
115 self.insert_switching_session(
116 SessionId::new("agent-switching-session"),
117 Vec::new(),
118 Some("Planner".to_string()),
119 false,
120 )
121 .await
122 }
123
124 pub async fn insert_agent_switching_session_with_serverless_coder(&self) -> FakeAgentSwitchingSession {
125 self.insert_switching_session(
126 SessionId::new("agent-switching-serverless-session"),
127 Vec::new(),
128 Some("Planner".to_string()),
129 true,
130 )
131 .await
132 }
133
134 pub async fn insert_loaded_agent_switching_session(&self, session_id: &str) -> FakeAgentSwitchingSession {
135 let events = self.session_store.load(session_id).map(|(_, events)| events).unwrap_or_default();
136 let selected_mode = last_agent_from_events(Some("Planner".to_string()), &events);
137 self.insert_switching_session(SessionId::new(session_id), events, selected_mode, false).await
138 }
139
140 pub async fn expect_mcp_server_status(&mut self, expected: &[&str]) {
141 assert_server_status(self.peer.next_mcp_notification().await, expected);
142 }
143
144 pub async fn expect_mcp_server_status_exact(&mut self, expected: &[&str]) {
145 assert_server_status_exact(self.peer.next_mcp_notification().await, expected);
146 }
147
148 pub async fn expect_available_commands(&mut self, expected: &[&str], unexpected: &[&str]) {
149 loop {
150 let update = self.peer.next_session_notification().await.update;
151 if matches!(update, SessionUpdate::AvailableCommandsUpdate(_)) {
152 assert_available_commands(update, expected, unexpected);
153 return;
154 }
155 }
156 }
157
158 pub fn append_agent_switch(&self, session_id: &str, from: Option<&str>, to: Option<&str>) {
159 self.append_stored_event(
160 session_id,
161 &SessionEvent::Control(SessionControlEvent::AgentSwitched {
162 from: from.map(str::to_string),
163 to: to.map(str::to_string),
164 }),
165 );
166 }
167
168 pub async fn insert_stub_session(
174 &self,
175 agent_tx: mpsc::Sender<Command>,
176 agent_rx: mpsc::Receiver<AgentMessage>,
177 agent_handle: AgentHandle,
178 id: SessionId,
179 model: &str,
180 ) {
181 let model_spec: llm::catalog::LlmModel = "anthropic:claude-sonnet-4-5".parse().expect("test model parses");
182 let spec = AgentSpec::default_spec(&model_spec, None, Vec::new());
183 let factory = Arc::new(StubRuntimeFactory {
184 cwd: PathBuf::from("/tmp"),
185 agent_parts: Mutex::new(Some(StubAgentParts { tx: agent_tx, rx: agent_rx, handle: agent_handle })),
186 });
187
188 let handle = SessionActor::spawn(SessionActorInit {
189 session_id: id.clone(),
190 connection: self.agent_cx.clone(),
191 repository: self.session_store.clone(),
192 oauth_credential_store: fake_oauth_store(),
193 active_agent: AgentKey::Default,
194 specs: HashMap::from([(AgentKey::Default, spec)]),
195 runtime_factory: factory,
196 transcript: Vec::new(),
197 modes: Modes::default(),
198 config: SessionConfigState::with_selection(model.to_string(), None, None),
199 })
200 .await
201 .expect("stub session actor spawns");
202 self.state.register_session(&id, handle).await;
203 }
204
205 pub fn append_stored_session(&self, session_id: &str, created_at: &str) {
206 self.append_stored_session_in(session_id, created_at, std::path::Path::new("/tmp"));
207 }
208
209 pub fn append_stored_session_in(&self, session_id: &str, created_at: &str, cwd: &std::path::Path) {
210 let meta = SessionMeta {
211 session_id: session_id.to_string(),
212 cwd: cwd.to_path_buf(),
213 model: "test-model".to_string(),
214 selected_mode: None,
215 created_at: created_at.to_string(),
216 };
217
218 self.session_store.append_meta(session_id, &meta).expect("stored session meta appends");
219 }
220
221 pub fn append_stored_prompt(&self, session_id: &str, prompt: &str) {
222 self.append_stored_event(
223 session_id,
224 &SessionEvent::User(UserEvent::Message { content: vec![llm::ContentBlock::text(prompt)] }),
225 );
226 }
227
228 pub fn append_stored_user_blocks(&self, session_id: &str, blocks: Vec<llm::ContentBlock>) {
229 self.append_stored_event(session_id, &SessionEvent::User(UserEvent::Message { content: blocks }));
230 }
231
232 pub fn append_stored_agent_text(&self, session_id: &str, text: &str) {
233 self.append_stored_event(
234 session_id,
235 &SessionEvent::Agent(AgentMessage::Text {
236 message_id: "msg".to_string(),
237 chunk: text.to_string(),
238 is_complete: true,
239 model_name: "test".to_string(),
240 }),
241 );
242 }
243
244 async fn insert_switching_session(
245 &self,
246 acp_session_id: SessionId,
247 events: Vec<SessionEvent>,
248 selected_mode: Option<String>,
249 serverless_coder: bool,
250 ) -> FakeAgentSwitchingSession {
251 let (planner_def, planner) = fake_agent("Planner", "planner-mcp", "plan", PLANNER_REPLY);
252 let (mut coder_def, coder) = fake_agent("Coder", "coder-mcp", "edit", CODER_REPLY);
253 if serverless_coder {
254 coder_def.mcp = None;
255 }
256
257 let mut specs = HashMap::new();
258 let mut agents = HashMap::new();
259 for def in [planner_def, coder_def] {
260 specs.insert(AgentKey::Named(def.spec.name.clone()), def.spec.clone());
261 agents.insert(def.spec.name.clone(), def);
262 }
263
264 let factory = Arc::new(FakeRuntimeFactory { cwd: PathBuf::from("/tmp"), agents });
265 let initial_agent = selected_mode.clone().unwrap_or_else(|| "Planner".to_string());
266
267 let handle = SessionActor::spawn(SessionActorInit {
268 session_id: acp_session_id.clone(),
269 connection: self.agent_cx.clone(),
270 repository: self.session_store.clone(),
271 oauth_credential_store: fake_oauth_store(),
272 active_agent: AgentKey::Named(initial_agent),
273 specs,
274 runtime_factory: factory,
275 transcript: events,
276 modes: switching_modes(),
277 config: SessionConfigState::with_selection("anthropic:claude-sonnet-4-5".to_string(), selected_mode, None),
278 })
279 .await
280 .expect("fake agent switching session actor spawns");
281 self.state.register_session(&acp_session_id, handle).await;
282 FakeAgentSwitchingSession { session_id: acp_session_id, planner, coder }
283 }
284
285 fn append_stored_event(&self, session_id: &str, event: &SessionEvent) {
286 self.session_store.append_event(session_id, event).expect("stored session event appends");
287 }
288}
289
290impl FakeAgentSwitchingSession {
291 pub fn session_id(&self) -> &SessionId {
292 &self.session_id
293 }
294
295 pub fn planner(&self) -> &FakeAcpAgent {
296 &self.planner
297 }
298
299 pub fn coder(&self) -> &FakeAcpAgent {
300 &self.coder
301 }
302
303 pub fn agent(&self, name: &str) -> &FakeAcpAgent {
304 match name {
305 "Planner" => &self.planner,
306 "Coder" => &self.coder,
307 other => panic!("unknown fake ACP agent {other:?}"),
308 }
309 }
310}
311
312impl FakeAcpAgent {
313 pub fn name(&self) -> &str {
314 &self.name
315 }
316
317 pub fn assert_saw(&self, expected: &[&str]) {
320 let seen = self.latest_conversation();
321 for text in expected {
322 assert!(seen.iter().any(|m| m == text), "{} should have seen {text:?}; saw {seen:?}", self.name);
323 }
324 }
325
326 pub fn assert_saw_exactly(&self, expected: &[&str]) {
330 let seen = self.latest_conversation();
331 let expected: Vec<String> = expected.iter().map(|t| (*t).to_string()).collect();
332 assert_eq!(seen, expected, "{} conversation mismatch", self.name);
333 }
334
335 pub fn assert_never_ran(&self) {
337 let contexts = self.captured_contexts.lock().expect("captured contexts lock is healthy");
338 assert!(contexts.is_empty(), "{} should not have run; captured {} context(s)", self.name, contexts.len());
339 }
340
341 fn latest_conversation(&self) -> Vec<String> {
342 let contexts = self.captured_contexts.lock().expect("captured contexts lock is healthy");
343 let latest = contexts.last().unwrap_or_else(|| panic!("{} should have run a turn", self.name));
344 conversation_texts(latest)
345 }
346}
347
348struct FakeRuntimeFactory {
352 cwd: PathBuf,
353 agents: HashMap<String, FakeAgentDef>,
354}
355
356struct FakeAgentDef {
357 spec: AgentSpec,
358 provider: Mutex<Option<Arc<dyn StreamingModelProvider>>>,
359 mcp: Option<(String, String)>,
360}
361
362#[async_trait::async_trait]
363impl RuntimeFactory for FakeRuntimeFactory {
364 async fn spawn(
365 &self,
366 agent: AgentKey,
367 spec: &AgentSpec,
368 initial_messages: Vec<ChatMessage>,
369 runtime_event_tx: mpsc::Sender<RuntimeEvent>,
370 ) -> Result<AgentRuntime, SessionError> {
371 let def = self.agents.get(&spec.name).ok_or_else(|| SessionError::AgentNotFound(spec.name.clone()))?;
372 let provider = def
373 .provider
374 .lock()
375 .expect("fake provider lock is healthy")
376 .take()
377 .expect("fake agent runtime spawned more than once");
378
379 let servers = def.mcp.as_ref().map_or_else(Vec::new, |(server_name, prompt_name)| {
380 vec![McpServer::new(
381 server_name.clone(),
382 McpTransport::InMemory { server: FakePromptMcp::new(prompt_name).into_dyn() },
383 false,
384 )]
385 });
386 let mut spawn = mcp(&self.cwd)
387 .with_servers(servers)
388 .spawn()
389 .await
390 .map_err(|e| SessionError::Build(CliError::McpError(e.to_string())))?;
391 let snapshot = spawn
392 .block_until_ready()
393 .await
394 .ok_or_else(|| SessionError::McpOperation("fake MCP bootstrap aborted".to_string()))?;
395 let McpSpawnResult { command_tx: mcp_tx, event_rx, handle: mcp_handle } = spawn;
396
397 let filtered_tools = spec.tools.apply(snapshot.tool_definitions.clone());
398 let mut builder = AgentBuilder::new(provider).max_auto_continues(0);
399 for prompt in &spec.prompts {
400 builder = builder.system_prompt(prompt.clone());
401 }
402 let (agent_tx, agent_rx, agent_handle) = builder
403 .tools(mcp_tx.clone(), filtered_tools)
404 .messages(initial_messages)
405 .spawn()
406 .await
407 .map_err(|e| SessionError::Build(CliError::AgentError(e.to_string())))?;
408
409 Ok(AgentRuntime::new(
410 agent,
411 spec,
412 agent_tx,
413 agent_rx,
414 Some(agent_handle),
415 mcp_tx,
416 event_rx,
417 mcp_handle,
418 snapshot,
419 runtime_event_tx,
420 ))
421 }
422}
423
424struct StubRuntimeFactory {
425 cwd: PathBuf,
426 agent_parts: Mutex<Option<StubAgentParts>>,
427}
428
429struct StubAgentParts {
430 tx: mpsc::Sender<Command>,
431 rx: mpsc::Receiver<AgentMessage>,
432 handle: AgentHandle,
433}
434
435#[async_trait::async_trait]
436impl RuntimeFactory for StubRuntimeFactory {
437 async fn spawn(
438 &self,
439 agent: AgentKey,
440 spec: &AgentSpec,
441 _initial_messages: Vec<ChatMessage>,
442 runtime_event_tx: mpsc::Sender<RuntimeEvent>,
443 ) -> Result<AgentRuntime, SessionError> {
444 let parts = self
445 .agent_parts
446 .lock()
447 .expect("stub agent parts lock is healthy")
448 .take()
449 .expect("stub runtime spawned more than once");
450
451 let mut spawn =
452 mcp(&self.cwd).spawn().await.map_err(|e| SessionError::Build(CliError::McpError(e.to_string())))?;
453 let snapshot = spawn
454 .block_until_ready()
455 .await
456 .ok_or_else(|| SessionError::McpOperation("stub MCP bootstrap aborted".to_string()))?;
457 let McpSpawnResult { command_tx: mcp_tx, event_rx, handle: mcp_handle } = spawn;
458
459 Ok(AgentRuntime::new(
460 agent,
461 spec,
462 parts.tx,
463 parts.rx,
464 Some(parts.handle),
465 mcp_tx,
466 event_rx,
467 mcp_handle,
468 snapshot,
469 runtime_event_tx,
470 ))
471 }
472}
473
474fn fake_agent(name: &str, server_name: &str, prompt_name: &str, reply: &str) -> (FakeAgentDef, FakeAcpAgent) {
475 let provider =
476 FakeLlmProvider::new(vec![vec![LlmResponse::start("msg"), LlmResponse::text(reply), LlmResponse::done()]])
477 .with_display_name(name);
478 let captured_contexts = provider.captured_contexts();
479 let def = FakeAgentDef {
480 spec: fake_agent_spec(name),
481 provider: Mutex::new(Some(Arc::new(provider))),
482 mcp: Some((server_name.to_string(), prompt_name.to_string())),
483 };
484 let observer = FakeAcpAgent { name: name.to_string(), captured_contexts };
485 (def, observer)
486}
487
488fn fake_oauth_store() -> Arc<dyn OAuthCredentialStorage> {
489 Arc::new(aether_auth::FakeOAuthCredentialStore::new())
490}
491
492fn switching_modes() -> Modes {
493 Modes::new(vec![
494 ValidatedMode {
495 name: "Planner".to_string(),
496 model: "anthropic:claude-sonnet-4-5".to_string(),
497 reasoning_effort: None,
498 },
499 ValidatedMode {
500 name: "Coder".to_string(),
501 model: "deepseek:deepseek-chat".to_string(),
502 reasoning_effort: None,
503 },
504 ])
505}
506
507fn fake_agent_spec(name: &str) -> AgentSpec {
508 let model: llm::catalog::LlmModel = "anthropic:claude-sonnet-4-5".parse().expect("test model parses");
509 let mut spec = AgentSpec::default_spec(&model, None, vec![Prompt::text(&format!("{name} system prompt"))]);
510 spec.name = name.to_string();
511 spec.description = format!("{name} test agent");
512 spec.exposure = AgentSpecExposure::user_only();
513 spec
514}
515
516fn assert_available_commands(update: SessionUpdate, expected: &[&str], unexpected: &[&str]) {
517 let SessionUpdate::AvailableCommandsUpdate(commands) = update else {
518 panic!("expected available commands update");
519 };
520 let names = commands.available_commands.iter().map(|command| command.name.as_str()).collect::<Vec<_>>();
521 for name in expected {
522 assert!(names.contains(name), "expected command /{name} in {names:?}");
523 }
524 for name in unexpected {
525 assert!(!names.contains(name), "did not expect command /{name} in {names:?}");
526 }
527}
528
529fn assert_server_status(notification: McpNotification, expected: &[&str]) {
530 let McpNotification::ServerStatus { servers } = notification else {
531 panic!("expected server status notification");
532 };
533 let names = servers.iter().map(|server| server.name.as_str()).collect::<Vec<_>>();
534 for server_name in expected {
535 assert!(names.contains(server_name), "expected server {server_name} in {names:?}");
536 }
537}
538
539fn assert_server_status_exact(notification: McpNotification, expected: &[&str]) {
540 let McpNotification::ServerStatus { servers } = notification else {
541 panic!("expected server status notification");
542 };
543 let names = servers.iter().map(|server| server.name.as_str()).collect::<Vec<_>>();
544 assert_eq!(names, expected);
545}
546
547fn conversation_texts(context: &Context) -> Vec<String> {
548 context
549 .messages()
550 .iter()
551 .filter_map(|message| match message {
552 ChatMessage::User { content, .. } => llm::ContentBlock::first_text(content).map(str::to_string),
553 ChatMessage::Assistant { content, .. } if !content.is_empty() => Some(content.clone()),
554 _ => None,
555 })
556 .collect()
557}