| 1 | //! `serve` over an in-memory channel: the agent and its client in one process, no stdio. |
| 2 | |
| 3 | use std::sync::Arc; |
| 4 | |
| 5 | use agent_client_protocol::schema::ProtocolVersion; |
| 6 | use agent_client_protocol::schema::v1::InitializeRequest; |
| 7 | use agent_client_protocol::{Agent, Channel, Client, ConnectionTo}; |
| 8 | use ed_acp::{AgentInfo, LlmEnv, Profile, PromptCtx, ServeOptions, Toolset}; |
| 9 | |
| 10 | struct Embedded; |
| 11 | |
| 12 | impl Profile for Embedded { |
| 13 | fn info(&self) -> AgentInfo { |
| 14 | AgentInfo { |
| 15 | name: "embedded-agent", |
| 16 | display_name: "Embedded", |
| 17 | version: "0.0.1", |
| 18 | env: LlmEnv { |
| 19 | prefix: "ED_ACP_IN_PROCESS_TEST", |
| 20 | dir_name: "ed-acp-in-process-test", |
| 21 | default_model: "test-model", |
| 22 | }, |
| 23 | } |
| 24 | } |
| 25 | |
| 26 | fn system_prompt(&self, _ctx: &PromptCtx<'_>) -> String { |
| 27 | String::new() |
| 28 | } |
| 29 | |
| 30 | fn toolsets(&self) -> Vec<Arc<dyn Toolset>> { |
| 31 | Vec::new() |
| 32 | } |
| 33 | } |
| 34 | |
| 35 | #[tokio::test] |
| 36 | async fn initialize_over_an_in_memory_channel() { |
| 37 | let (agent_end, client_end) = Channel::duplex(); |
| 38 | let opts = ServeOptions { |
| 39 | yolo: false, |
| 40 | surface: "app", |
| 41 | }; |
| 42 | let server = tokio::spawn(ed_acp::serve(Arc::new(Embedded), opts, agent_end)); |
| 43 | |
| 44 | let info = Client |
| 45 | .builder() |
| 46 | .connect_with(client_end, |conn: ConnectionTo<Agent>| async move { |
| 47 | conn.send_request(InitializeRequest::new(ProtocolVersion::V1)) |
| 48 | .block_task() |
| 49 | .await |
| 50 | }) |
| 51 | .await |
| 52 | .expect("initialize"); |
| 53 | |
| 54 | let agent = info.agent_info.expect("agentInfo"); |
| 55 | assert_eq!(agent.name, "embedded-agent"); |
| 56 | assert_eq!(agent.version, "0.0.1"); |
| 57 | let caps = info.agent_capabilities; |
| 58 | assert!(caps.load_session); |
| 59 | |
| 60 | // The client hung up when `connect_with` returned, so the server stops on its own. |
| 61 | tokio::time::timeout(std::time::Duration::from_secs(5), server) |
| 62 | .await |
| 63 | .expect("server stops after the client disconnects") |
| 64 | .expect("server task") |
| 65 | .ok(); |
| 66 | } |
| 67 | |
| 68 | mod provider_auth { |
| 69 | use std::sync::atomic::{AtomicUsize, Ordering}; |
| 70 | use std::sync::{Arc, Mutex}; |
| 71 | |
| 72 | use agent_client_protocol::schema::ProtocolVersion; |
| 73 | use agent_client_protocol::schema::v1::{ |
| 74 | AuthMethod, AuthMethodAgent, AuthenticateRequest, InitializeRequest, LogoutRequest, |
| 75 | NewSessionRequest, |
| 76 | }; |
| 77 | use agent_client_protocol::{Agent, Channel, Client, ConnectionTo}; |
| 78 | use async_trait::async_trait; |
| 79 | use ed_acp::{ |
| 80 | AgentInfo, AuthCtx, AuthProvider, LlmConfig, LlmEnv, Profile, PromptCtx, Provider, |
| 81 | ServeOptions, Toolset, |
| 82 | }; |
| 83 | |
| 84 | /// A provider whose "session" is a string it holds in memory. |
| 85 | #[derive(Default)] |
| 86 | struct State { |
| 87 | token: Mutex<Option<String>>, |
| 88 | authenticated: AtomicUsize, |
| 89 | } |
| 90 | |
| 91 | struct Fake(Arc<State>); |
| 92 | |
| 93 | #[async_trait] |
| 94 | impl AuthProvider for Fake { |
| 95 | fn method(&self) -> AuthMethod { |
| 96 | AuthMethod::Agent(AuthMethodAgent::new("fake", "Sign in to Fake")) |
| 97 | } |
| 98 | |
| 99 | fn llm_config(&self) -> Option<LlmConfig> { |
| 100 | self.0.token.lock().unwrap().clone().map(|token| LlmConfig { |
| 101 | provider: Provider::OpenAi, |
| 102 | // Nothing listens here: listing models fails fast and the default is offered. |
| 103 | base_url: "http://127.0.0.1:1".into(), |
| 104 | api_key: Some(token), |
| 105 | model: "fake-model".into(), |
| 106 | }) |
| 107 | } |
| 108 | |
| 109 | async fn authenticate(&self, ctx: AuthCtx<'_>) -> agent_client_protocol::Result<()> { |
| 110 | assert!(!ctx.client_offers_url_elicitation()); |
| 111 | self.0.authenticated.fetch_add(1, Ordering::SeqCst); |
| 112 | *self.0.token.lock().unwrap() = Some("token".into()); |
| 113 | Ok(()) |
| 114 | } |
| 115 | |
| 116 | async fn logout(&self) -> anyhow::Result<()> { |
| 117 | *self.0.token.lock().unwrap() = None; |
| 118 | Ok(()) |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | struct WithAuth(Arc<State>); |
| 123 | |
| 124 | impl Profile for WithAuth { |
| 125 | fn info(&self) -> AgentInfo { |
| 126 | AgentInfo { |
| 127 | name: "provider-agent", |
| 128 | display_name: "Provider", |
| 129 | version: "0.0.1", |
| 130 | env: LlmEnv { |
| 131 | prefix: "ED_ACP_PROVIDER_TEST", |
| 132 | dir_name: "ed-acp-provider-test", |
| 133 | default_model: "fake-model", |
| 134 | }, |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | fn system_prompt(&self, _ctx: &PromptCtx<'_>) -> String { |
| 139 | String::new() |
| 140 | } |
| 141 | |
| 142 | fn toolsets(&self) -> Vec<Arc<dyn Toolset>> { |
| 143 | Vec::new() |
| 144 | } |
| 145 | |
| 146 | fn auth(&self) -> Option<Arc<dyn AuthProvider>> { |
| 147 | Some(Arc::new(Fake(self.0.clone()))) |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | #[tokio::test] |
| 152 | async fn the_provider_owns_initialize_sessions_authenticate_and_logout() { |
| 153 | let fake = Arc::new(State::default()); |
| 154 | let (agent_end, client_end) = Channel::duplex(); |
| 155 | let opts = ServeOptions { |
| 156 | yolo: false, |
| 157 | surface: "app", |
| 158 | }; |
| 159 | tokio::spawn(ed_acp::serve( |
| 160 | Arc::new(WithAuth(fake.clone())), |
| 161 | opts, |
| 162 | agent_end, |
| 163 | )); |
| 164 | let cwd = std::env::temp_dir(); |
| 165 | |
| 166 | Client |
| 167 | .builder() |
| 168 | .connect_with(client_end, |conn: ConnectionTo<Agent>| async move { |
| 169 | // The client cannot run terminal auth, and the method is offered anyway. |
| 170 | let init = conn |
| 171 | .send_request(InitializeRequest::new(ProtocolVersion::V1)) |
| 172 | .block_task() |
| 173 | .await?; |
| 174 | assert_eq!(init.auth_methods.len(), 1); |
| 175 | assert_eq!(&*init.auth_methods[0].id().0, "fake"); |
| 176 | |
| 177 | let signed_out = conn |
| 178 | .send_request(NewSessionRequest::new(cwd.clone())) |
| 179 | .block_task() |
| 180 | .await; |
| 181 | assert!(signed_out.is_err(), "no session while signed out"); |
| 182 | |
| 183 | let wrong = conn |
| 184 | .send_request(AuthenticateRequest::new("terminal-setup")) |
| 185 | .block_task() |
| 186 | .await; |
| 187 | assert!(wrong.is_err(), "only the provider's method is accepted"); |
| 188 | |
| 189 | conn.send_request(AuthenticateRequest::new("fake")) |
| 190 | .block_task() |
| 191 | .await?; |
| 192 | conn.send_request(NewSessionRequest::new(cwd.clone())) |
| 193 | .block_task() |
| 194 | .await?; |
| 195 | |
| 196 | conn.send_request(LogoutRequest::new()).block_task().await?; |
| 197 | let after = conn |
| 198 | .send_request(NewSessionRequest::new(cwd.clone())) |
| 199 | .block_task() |
| 200 | .await; |
| 201 | assert!(after.is_err(), "signed out again"); |
| 202 | Ok(()) |
| 203 | }) |
| 204 | .await |
| 205 | .expect("conversation"); |
| 206 | |
| 207 | assert_eq!(fake.authenticated.load(Ordering::SeqCst), 1); |
| 208 | } |
| 209 | } |