Skip to main content

msg_gateway/
backend.rs

1//! Backend adapter implementations for different backend protocols.
2//!
3//! Each backend adapter handles sending messages to a specific backend type
4//! (Pipelit, OpenCode, etc.) and manages responses if needed.
5
6use async_trait::async_trait;
7use std::collections::HashMap;
8use std::sync::{Arc, OnceLock};
9use tokio::sync::Mutex;
10
11use crate::config::{BackendConfig, BackendProtocol, CredentialConfig, GatewayConfig};
12use crate::message::InboundMessage;
13
14/// Gateway context passed to backend adapters that need to call back into the gateway
15pub struct GatewayContext {
16    pub gateway_url: String,
17    pub send_token: String,
18}
19
20/// Shared session map for OpenCode adapters (persists across per-request adapter creation).
21/// Uses a Mutex (not RwLock) so that session creation is serialized per-process,
22/// preventing duplicate session creation under concurrent requests for the same chat_id.
23static OPENCODE_SESSIONS: OnceLock<Arc<Mutex<HashMap<String, String>>>> = OnceLock::new();
24
25fn get_opencode_sessions() -> Arc<Mutex<HashMap<String, String>>> {
26    OPENCODE_SESSIONS
27        .get_or_init(|| Arc::new(Mutex::new(HashMap::new())))
28        .clone()
29}
30
31/// Error type for backend operations
32#[derive(Debug, thiserror::Error)]
33pub enum BackendError {
34    #[error("Network error: {0}")]
35    Network(#[from] reqwest::Error),
36
37    #[error("Backend returned error: {status} - {message}")]
38    #[allow(clippy::enum_variant_names)]
39    BackendResponse { status: u16, message: String },
40
41    #[error("Invalid configuration: {0}")]
42    InvalidConfig(String),
43
44    #[error("Timeout waiting for response")]
45    #[allow(dead_code)]
46    Timeout,
47}
48
49/// Trait for backend adapters
50#[async_trait]
51pub trait BackendAdapter: Send + Sync {
52    /// Send a normalized message to the backend
53    async fn send_message(&self, message: &InboundMessage) -> Result<(), BackendError>;
54
55    /// Whether this backend supports file attachments
56    #[allow(dead_code)]
57    fn supports_files(&self) -> bool;
58}
59
60/// Pipelit backend adapter - fire-and-forget webhook
61pub struct PipelitAdapter {
62    client: reqwest::Client,
63    inbound_url: String,
64    token: String,
65}
66
67impl PipelitAdapter {
68    pub fn new(
69        target: &BackendConfig,
70        _gateway_ctx: Option<&GatewayContext>,
71        _credential_config: Option<&serde_json::Value>,
72    ) -> Result<Self, BackendError> {
73        let inbound_url = target.inbound_url.clone().ok_or_else(|| {
74            BackendError::InvalidConfig("Pipelit target requires inbound_url".to_string())
75        })?;
76
77        Ok(Self {
78            client: reqwest::Client::new(),
79            inbound_url,
80            token: target.token.clone(),
81        })
82    }
83}
84
85#[async_trait]
86impl BackendAdapter for PipelitAdapter {
87    async fn send_message(&self, message: &InboundMessage) -> Result<(), BackendError> {
88        let response = self
89            .client
90            .post(&self.inbound_url)
91            .header("Authorization", format!("Bearer {}", self.token))
92            .json(message)
93            .send()
94            .await?;
95
96        if response.status().is_success() {
97            Ok(())
98        } else {
99            let status = response.status().as_u16();
100            let message = response
101                .text()
102                .await
103                .unwrap_or_else(|_| "Unknown error".to_string());
104            Err(BackendError::BackendResponse { status, message })
105        }
106    }
107
108    fn supports_files(&self) -> bool {
109        true
110    }
111}
112
113pub struct OpencodeAdapter {
114    base_url: String,
115    token: String,
116    gateway_url: String,
117    send_token: String,
118    credential_config: Option<serde_json::Value>,
119    sessions: Arc<Mutex<HashMap<String, String>>>,
120}
121
122impl OpencodeAdapter {
123    pub fn new(
124        target: &BackendConfig,
125        gateway_ctx: Option<&GatewayContext>,
126        credential_config: Option<&serde_json::Value>,
127    ) -> Result<Self, BackendError> {
128        let base_url = target.base_url.clone().ok_or_else(|| {
129            BackendError::InvalidConfig("OpenCode target requires base_url".to_string())
130        })?;
131
132        Ok(Self {
133            base_url,
134            token: target.token.clone(),
135            gateway_url: gateway_ctx
136                .map(|ctx| ctx.gateway_url.clone())
137                .unwrap_or_default(),
138            send_token: gateway_ctx
139                .map(|ctx| ctx.send_token.clone())
140                .unwrap_or_default(),
141            credential_config: credential_config.cloned(),
142            sessions: get_opencode_sessions(),
143        })
144    }
145}
146
147#[async_trait]
148impl BackendAdapter for OpencodeAdapter {
149    async fn send_message(&self, message: &InboundMessage) -> Result<(), BackendError> {
150        // Parse auth credentials (split at first colon)
151        let colon_pos = self.token.find(':').ok_or_else(|| {
152            BackendError::InvalidConfig(
153                "OpenCode token must be in 'username:password' format".to_string(),
154            )
155        })?;
156        let username = &self.token[..colon_pos];
157        let password = &self.token[colon_pos + 1..];
158
159        let model = self
160            .credential_config
161            .as_ref()
162            .and_then(|c| c.get("model"))
163            .ok_or_else(|| {
164                BackendError::InvalidConfig("Missing 'model' in credential config".to_string())
165            })?;
166
167        // Build client with 120s timeout for LLM calls
168        let client = reqwest::Client::builder()
169            .timeout(std::time::Duration::from_secs(120))
170            .build()?;
171
172        let chat_id = &message.source.chat_id;
173        let session_key = format!("{}:{}", message.credential_id, chat_id);
174
175        let session_id = {
176            let mut sessions = self.sessions.lock().await;
177            if let Some(id) = sessions.get(&session_key) {
178                id.clone()
179            } else {
180                tracing::info!(
181                    credential_id = %message.credential_id,
182                    chat_id = %chat_id,
183                    "Creating new OpenCode session"
184                );
185
186                let resp = client
187                    .post(format!("{}/session", self.base_url))
188                    .basic_auth(username, Some(password))
189                    .send()
190                    .await?;
191
192                if !resp.status().is_success() {
193                    let status = resp.status().as_u16();
194                    let body = resp
195                        .text()
196                        .await
197                        .unwrap_or_else(|_| "Unknown error".to_string());
198                    return Err(BackendError::BackendResponse {
199                        status,
200                        message: body,
201                    });
202                }
203
204                let body: serde_json::Value = resp.json().await?;
205                let new_session_id = body
206                    .get("id")
207                    .and_then(|v| v.as_str())
208                    .ok_or_else(|| {
209                        BackendError::InvalidConfig(
210                            "OpenCode session response missing 'id' field".to_string(),
211                        )
212                    })?
213                    .to_string();
214
215                sessions.insert(session_key.clone(), new_session_id.clone());
216                new_session_id
217            }
218        };
219
220        tracing::info!(
221            credential_id = %message.credential_id,
222            chat_id = %chat_id,
223            session_id = %session_id,
224            "Sending message to OpenCode"
225        );
226
227        let msg_body = serde_json::json!({
228            "model": model,
229            "parts": [{"type": "text", "text": message.text}]
230        });
231
232        let resp = client
233            .post(format!("{}/session/{}/message", self.base_url, session_id))
234            .basic_auth(username, Some(password))
235            .json(&msg_body)
236            .send()
237            .await?;
238
239        if !resp.status().is_success() {
240            let status = resp.status().as_u16();
241            let body = resp
242                .text()
243                .await
244                .unwrap_or_else(|_| "Unknown error".to_string());
245            return Err(BackendError::BackendResponse {
246                status,
247                message: body,
248            });
249        }
250
251        // Extract AI response: join all text parts with "\n\n"
252        let resp_body: serde_json::Value = resp.json().await?;
253        let ai_response = resp_body
254            .get("parts")
255            .and_then(|v| v.as_array())
256            .map(|parts| {
257                parts
258                    .iter()
259                    .filter(|p| p.get("type").and_then(|t| t.as_str()) == Some("text"))
260                    .filter_map(|p| p.get("text").and_then(|t| t.as_str()))
261                    .collect::<Vec<_>>()
262                    .join("\n\n")
263            })
264            .unwrap_or_default();
265
266        // Self-relay: send AI response back through gateway to the user
267        tracing::info!(
268            credential_id = %message.credential_id,
269            chat_id = %chat_id,
270            "Relaying OpenCode response to gateway"
271        );
272
273        let relay_body = serde_json::json!({
274            "credential_id": message.credential_id,
275            "chat_id": chat_id,
276            "text": ai_response,
277        });
278
279        match client
280            .post(format!("{}/api/v1/send", self.gateway_url))
281            .header("Authorization", format!("Bearer {}", self.send_token))
282            .json(&relay_body)
283            .send()
284            .await
285        {
286            Ok(resp) if !resp.status().is_success() => {
287                let status = resp.status();
288                tracing::error!(
289                    credential_id = %message.credential_id,
290                    chat_id = %chat_id,
291                    error = %format!("HTTP {}", status),
292                    "Failed to relay OpenCode response"
293                );
294            }
295            Err(e) => {
296                tracing::error!(
297                    credential_id = %message.credential_id,
298                    chat_id = %chat_id,
299                    error = %e,
300                    "Failed to relay OpenCode response"
301                );
302            }
303            Ok(_) => {}
304        }
305
306        Ok(())
307    }
308
309    fn supports_files(&self) -> bool {
310        false // OpenCode does not support file attachments
311    }
312}
313
314pub struct ExternalBackendAdapter {
315    port: u16,
316    token: String,
317    client: reqwest::Client,
318}
319
320impl ExternalBackendAdapter {
321    pub fn new(port: u16, token: String) -> Result<Self, BackendError> {
322        Ok(Self {
323            port,
324            token,
325            client: reqwest::Client::builder()
326                .timeout(std::time::Duration::from_secs(120))
327                .build()?,
328        })
329    }
330}
331
332#[async_trait]
333impl BackendAdapter for ExternalBackendAdapter {
334    async fn send_message(&self, message: &InboundMessage) -> Result<(), BackendError> {
335        let url = format!("http://127.0.0.1:{}/send", self.port);
336
337        tracing::info!(
338            port = %self.port,
339            credential_id = %message.credential_id,
340            "Sending message to external backend adapter"
341        );
342
343        let response = self
344            .client
345            .post(&url)
346            .header("Authorization", format!("Bearer {}", self.token))
347            .json(message)
348            .send()
349            .await?;
350
351        if response.status().is_success() {
352            Ok(())
353        } else {
354            let status = response.status().as_u16();
355            let message = response
356                .text()
357                .await
358                .unwrap_or_else(|_| "Unknown error".to_string());
359            Err(BackendError::BackendResponse { status, message })
360        }
361    }
362
363    fn supports_files(&self) -> bool {
364        false
365    }
366}
367
368/// Manages lifecycle of external backend adapter subprocesses
369#[allow(dead_code)]
370pub struct ExternalBackendManager {
371    backends_dir: String,
372    port_allocator: crate::adapter::PortAllocator,
373    gateway_url: String,
374    gateway_send_token: String,
375    processes: tokio::sync::RwLock<HashMap<String, ExternalBackendProcess>>,
376}
377
378#[allow(dead_code)]
379pub struct ExternalBackendProcess {
380    pub instance_id: String,
381    pub port: u16,
382    pub token: String,
383    pub process: tokio::process::Child,
384    pub adapter_dir: String,
385}
386
387#[allow(dead_code)]
388impl ExternalBackendManager {
389    pub fn new(
390        backends_dir: String,
391        port_range: (u16, u16),
392        gateway_listen: &str,
393        gateway_send_token: String,
394    ) -> Self {
395        let gateway_url = if gateway_listen.starts_with("0.0.0.0") {
396            format!(
397                "http://127.0.0.1:{}",
398                gateway_listen.split(':').next_back().unwrap_or("8080")
399            )
400        } else {
401            format!("http://{}", gateway_listen)
402        };
403
404        Self {
405            backends_dir,
406            port_allocator: crate::adapter::PortAllocator::new(port_range),
407            gateway_url,
408            gateway_send_token,
409            processes: tokio::sync::RwLock::new(HashMap::new()),
410        }
411    }
412
413    pub async fn spawn(
414        &self,
415        backend_name: &str,
416        backend_cfg: &BackendConfig,
417    ) -> Result<(u16, String), BackendError> {
418        let adapter_dir = backend_cfg
419            .adapter_dir
420            .as_ref()
421            .map(std::path::PathBuf::from)
422            .unwrap_or_else(|| std::path::Path::new(&self.backends_dir).join(backend_name));
423
424        let adapter_def = crate::adapter::load_adapter_def(&adapter_dir).map_err(|e| {
425            BackendError::InvalidConfig(format!("Failed to load backend adapter def: {}", e))
426        })?;
427
428        let port = self.port_allocator.allocate().await.ok_or_else(|| {
429            BackendError::InvalidConfig("No available ports for backend adapter".to_string())
430        })?;
431
432        let instance_id = format!("backend_{}_{}", backend_name, uuid::Uuid::new_v4());
433        let backend_token = uuid::Uuid::new_v4().to_string();
434
435        let mut cmd = tokio::process::Command::new(&adapter_def.command);
436        cmd.args(&adapter_def.args)
437            .current_dir(&adapter_dir)
438            .env("INSTANCE_ID", &instance_id)
439            .env("BACKEND_PORT", port.to_string())
440            .env("GATEWAY_URL", &self.gateway_url)
441            .env("BACKEND_TOKEN", &backend_token)
442            .env("GATEWAY_SEND_TOKEN", &self.gateway_send_token)
443            .stdin(std::process::Stdio::null())
444            .stdout(std::process::Stdio::null())
445            .stderr(std::process::Stdio::piped());
446
447        if let Some(cfg) = &backend_cfg.config {
448            cmd.env(
449                "BACKEND_CONFIG",
450                serde_json::to_string(cfg).unwrap_or_default(),
451            );
452        }
453
454        tracing::info!(
455            backend = %backend_name,
456            port = %port,
457            instance_id = %instance_id,
458            "Spawning external backend adapter process"
459        );
460
461        let mut process = match cmd.spawn() {
462            Ok(p) => p,
463            Err(e) => {
464                self.port_allocator.release(port).await;
465                return Err(BackendError::InvalidConfig(format!(
466                    "Failed to spawn backend adapter process: {}",
467                    e
468                )));
469            }
470        };
471
472        // Forward subprocess stderr to tracing (Node.js adapter logs to stderr)
473        if let Some(stderr) = process.stderr.take() {
474            let name = backend_name.to_string();
475            tokio::spawn(async move {
476                use tokio::io::{AsyncBufReadExt, BufReader};
477                let mut lines = BufReader::new(stderr).lines();
478                while let Ok(Some(line)) = lines.next_line().await {
479                    tracing::info!(backend = %name, "{}", line);
480                }
481            });
482        }
483
484        let mut processes = self.processes.write().await;
485        processes.insert(
486            backend_name.to_string(),
487            ExternalBackendProcess {
488                instance_id,
489                port,
490                token: backend_token.clone(),
491                process,
492                adapter_dir: adapter_dir.to_string_lossy().to_string(),
493            },
494        );
495
496        Ok((port, backend_token))
497    }
498
499    #[allow(dead_code)]
500    pub async fn get_port(&self, backend_name: &str) -> Option<u16> {
501        let processes = self.processes.read().await;
502        processes.get(backend_name).map(|p| p.port)
503    }
504
505    pub async fn stop(&self, backend_name: &str) {
506        let mut processes = self.processes.write().await;
507        if let Some(mut process) = processes.remove(backend_name) {
508            let _ = process.process.kill().await;
509            let _ = process.process.wait().await;
510            self.port_allocator.release(process.port).await;
511            tracing::info!(
512                backend = %backend_name,
513                port = %process.port,
514                "Stopped external backend adapter"
515            );
516        }
517    }
518
519    pub async fn stop_all(&self) {
520        let mut processes = self.processes.write().await;
521        for (name, mut process) in processes.drain() {
522            let _ = process.process.kill().await;
523            let _ = process.process.wait().await;
524            self.port_allocator.release(process.port).await;
525            tracing::info!(
526                backend = %name,
527                port = %process.port,
528                "Stopped external backend adapter"
529            );
530        }
531    }
532}
533
534pub fn create_adapter(
535    target: &BackendConfig,
536    gateway_ctx: Option<&GatewayContext>,
537    credential_config: Option<&serde_json::Value>,
538) -> Result<Arc<dyn BackendAdapter>, BackendError> {
539    match target.protocol {
540        BackendProtocol::Pipelit => Ok(Arc::new(PipelitAdapter::new(
541            target,
542            gateway_ctx,
543            credential_config,
544        )?)),
545        BackendProtocol::Opencode => Ok(Arc::new(OpencodeAdapter::new(
546            target,
547            gateway_ctx,
548            credential_config,
549        )?)),
550        BackendProtocol::External => {
551            let port = target.port.ok_or_else(|| {
552                BackendError::InvalidConfig(
553                    "External backend adapter requires 'port' in backend config".to_string(),
554                )
555            })?;
556            Ok(Arc::new(ExternalBackendAdapter::new(
557                port,
558                target.token.clone(),
559            )?))
560        }
561    }
562}
563
564/// Resolve the backend name for a credential.
565/// Returns the credential's explicit backend name, or the gateway's default_backend.
566pub fn resolve_backend_name(
567    credential: &CredentialConfig,
568    gateway: &GatewayConfig,
569) -> Option<String> {
570    credential
571        .backend
572        .clone()
573        .or_else(|| gateway.default_backend.clone())
574}
575
576/// Poll an external backend's health endpoint until it responds 200 or timeout expires.
577pub async fn wait_for_backend_ready(
578    port: u16,
579    timeout: std::time::Duration,
580    interval: std::time::Duration,
581) -> bool {
582    let client = reqwest::Client::builder()
583        .timeout(std::time::Duration::from_secs(2))
584        .build()
585        .unwrap_or_else(|_| reqwest::Client::new());
586
587    let url = format!("http://127.0.0.1:{}/health", port);
588    let deadline = tokio::time::Instant::now() + timeout;
589
590    loop {
591        match client.get(&url).send().await {
592            Ok(resp) if resp.status().is_success() => return true,
593            _ => {}
594        }
595        if tokio::time::Instant::now() >= deadline {
596            return false;
597        }
598        tokio::time::sleep(interval).await;
599    }
600}
601
602#[cfg(test)]
603mod tests {
604    use super::*;
605    use crate::config::{BackendConfig, BackendProtocol};
606    use crate::message::{InboundMessage, MessageSource, UserInfo};
607
608    fn make_opencode_target(token: &str) -> BackendConfig {
609        BackendConfig {
610            protocol: BackendProtocol::Opencode,
611            inbound_url: None,
612            base_url: Some("http://localhost:4096".to_string()),
613            token: token.to_string(),
614            poll_interval_ms: None,
615            adapter_dir: None,
616            port: None,
617            active: true,
618            config: None,
619        }
620    }
621
622    fn make_dummy_message() -> InboundMessage {
623        InboundMessage {
624            route: serde_json::json!({}),
625            credential_id: "test_cred".to_string(),
626            source: MessageSource {
627                protocol: "test".to_string(),
628                chat_id: "chat_123".to_string(),
629                message_id: "msg_1".to_string(),
630                reply_to_message_id: None,
631                from: UserInfo {
632                    id: "user_1".to_string(),
633                    username: None,
634                    display_name: None,
635                },
636            },
637            text: "Hello".to_string(),
638            attachments: vec![],
639            timestamp: chrono::Utc::now(),
640            extra_data: None,
641        }
642    }
643
644    #[test]
645    fn test_pipelit_adapter_requires_inbound_url() {
646        let target = BackendConfig {
647            protocol: BackendProtocol::Pipelit,
648            inbound_url: None,
649            base_url: None,
650            token: "test".to_string(),
651            poll_interval_ms: None,
652            adapter_dir: None,
653            port: None,
654            active: true,
655            config: None,
656        };
657
658        let result = PipelitAdapter::new(&target, None, None);
659        assert!(result.is_err());
660    }
661
662    #[test]
663    fn test_pipelit_adapter_creation() {
664        let target = BackendConfig {
665            protocol: BackendProtocol::Pipelit,
666            inbound_url: Some("http://localhost:8000/inbound".to_string()),
667            base_url: None,
668            token: "test".to_string(),
669            poll_interval_ms: None,
670            adapter_dir: None,
671            port: None,
672            active: true,
673            config: None,
674        };
675
676        let adapter = PipelitAdapter::new(&target, None, None).unwrap();
677        assert!(adapter.supports_files());
678    }
679
680    #[test]
681    fn test_opencode_adapter_requires_base_url() {
682        let target = BackendConfig {
683            protocol: BackendProtocol::Opencode,
684            inbound_url: None,
685            base_url: None,
686            token: "test".to_string(),
687            poll_interval_ms: None,
688            adapter_dir: None,
689            port: None,
690            active: true,
691            config: None,
692        };
693
694        let result = OpencodeAdapter::new(&target, None, None);
695        assert!(result.is_err());
696    }
697
698    #[test]
699    fn test_opencode_adapter_creation() {
700        let target = BackendConfig {
701            protocol: BackendProtocol::Opencode,
702            inbound_url: None,
703            base_url: Some("http://localhost:4096".to_string()),
704            token: "test".to_string(),
705            poll_interval_ms: Some(1000),
706            adapter_dir: None,
707            port: None,
708            active: true,
709            config: None,
710        };
711
712        let adapter = OpencodeAdapter::new(&target, None, None).unwrap();
713        assert!(!adapter.supports_files());
714    }
715
716    #[test]
717    fn test_opencode_adapter_valid_token_parsing() {
718        let target = make_opencode_target("myuser:mypass");
719        let result = OpencodeAdapter::new(&target, None, None);
720        assert!(result.is_ok(), "Adapter with valid token should succeed");
721    }
722
723    #[tokio::test]
724    async fn test_opencode_adapter_token_no_colon() {
725        let target = make_opencode_target("nodelimiter");
726        let adapter = OpencodeAdapter::new(&target, None, None).unwrap();
727        let msg = make_dummy_message();
728        let result = adapter.send_message(&msg).await;
729        assert!(result.is_err());
730        let err_str = result.unwrap_err().to_string();
731        assert!(
732            err_str.contains("username:password"),
733            "Error should mention 'username:password', got: {err_str}"
734        );
735    }
736
737    #[test]
738    fn test_opencode_adapter_token_colon_in_password() {
739        let target = make_opencode_target("user:pass:with:colons");
740        let result = OpencodeAdapter::new(&target, None, None);
741        assert!(
742            result.is_ok(),
743            "Token with colon in password should be accepted"
744        );
745    }
746
747    #[tokio::test]
748    async fn test_opencode_adapter_missing_credential_config() {
749        let target = make_opencode_target("user:pass");
750        let adapter = OpencodeAdapter::new(&target, None, None).unwrap();
751        let msg = make_dummy_message();
752        let result = adapter.send_message(&msg).await;
753        assert!(result.is_err());
754        let err_str = result.unwrap_err().to_string();
755        assert!(
756            err_str.contains("model"),
757            "Error should mention 'model', got: {err_str}"
758        );
759    }
760
761    #[tokio::test]
762    async fn test_opencode_adapter_missing_model_in_config() {
763        let target = make_opencode_target("user:pass");
764        let config = serde_json::json!({});
765        let adapter = OpencodeAdapter::new(&target, None, Some(&config)).unwrap();
766        let msg = make_dummy_message();
767        let result = adapter.send_message(&msg).await;
768        assert!(result.is_err());
769        let err_str = result.unwrap_err().to_string();
770        assert!(
771            err_str.contains("model"),
772            "Error should mention 'model', got: {err_str}"
773        );
774    }
775
776    #[test]
777    fn test_opencode_adapter_valid_model_config() {
778        let target = make_opencode_target("user:pass");
779        let config = serde_json::json!({
780            "model": {
781                "providerID": "test",
782                "modelID": "test-model"
783            }
784        });
785        let result = OpencodeAdapter::new(&target, None, Some(&config));
786        assert!(
787            result.is_ok(),
788            "Adapter with valid model config should succeed"
789        );
790    }
791
792    #[test]
793    fn test_opencode_adapter_creation_with_gateway_ctx() {
794        let target = make_opencode_target("user:pass");
795        let config = serde_json::json!({
796            "model": {
797                "providerID": "test",
798                "modelID": "test-model"
799            }
800        });
801        let gateway_ctx = GatewayContext {
802            gateway_url: "http://localhost:8080".to_string(),
803            send_token: "test_token".to_string(),
804        };
805        let result = OpencodeAdapter::new(&target, Some(&gateway_ctx), Some(&config));
806        assert!(
807            result.is_ok(),
808            "Adapter with full valid config should succeed"
809        );
810    }
811
812    #[test]
813    fn test_opencode_adapter_supports_files_false() {
814        let target = make_opencode_target("user:pass");
815        let adapter = OpencodeAdapter::new(&target, None, None).unwrap();
816        assert!(
817            !adapter.supports_files(),
818            "OpencodeAdapter should not support files"
819        );
820    }
821
822    #[test]
823    fn test_external_backend_adapter_creation() {
824        let adapter = ExternalBackendAdapter::new(9200, "test_token".to_string()).unwrap();
825        assert_eq!(adapter.port, 9200);
826        assert_eq!(adapter.token, "test_token");
827    }
828
829    #[test]
830    fn test_external_backend_adapter_supports_files() {
831        let adapter = ExternalBackendAdapter::new(9200, "token".to_string()).unwrap();
832        assert!(
833            !adapter.supports_files(),
834            "ExternalBackendAdapter should not support files"
835        );
836    }
837
838    #[test]
839    fn test_create_adapter_external_requires_port() {
840        let target = BackendConfig {
841            protocol: BackendProtocol::External,
842            inbound_url: None,
843            base_url: None,
844            token: "test".to_string(),
845            poll_interval_ms: None,
846            adapter_dir: Some("./backends/opencode".to_string()),
847            port: None,
848            active: true,
849            config: None,
850        };
851
852        let result = create_adapter(&target, None, None);
853        assert!(result.is_err());
854        let err_str = match result {
855            Err(e) => e.to_string(),
856            Ok(_) => panic!("Expected error"),
857        };
858        assert!(
859            err_str.contains("port"),
860            "Error should mention 'port', got: {err_str}"
861        );
862    }
863
864    #[test]
865    fn test_create_adapter_external_with_port() {
866        let target = BackendConfig {
867            protocol: BackendProtocol::External,
868            inbound_url: None,
869            base_url: None,
870            token: "ext_token".to_string(),
871            poll_interval_ms: None,
872            adapter_dir: Some("./backends/opencode".to_string()),
873            port: Some(9200),
874            active: true,
875            config: None,
876        };
877
878        let result = create_adapter(&target, None, None);
879        assert!(result.is_ok(), "External adapter with port should succeed");
880    }
881}