Skip to main content

pawan/
eruka_bridge.rs

1//! Eruka bridge — connect Eruka's 3-tier memory to pawan's context window
2//!
3//! When enabled, pawan injects Core memory before each LLM call and
4//! archives completed sessions to Eruka's Archival tier.
5//!
6//! This wires the DIRMACS context engine into the coding agent.
7
8use crate::agent::session::Session;
9use crate::agent::{Message, Role};
10use crate::{PawanError, Result};
11use serde::{Deserialize, Serialize};
12
13/// Eruka client configuration
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct ErukaConfig {
16    /// Whether Eruka integration is enabled
17    #[serde(default)]
18    pub enabled: bool,
19    /// Eruka API URL (default: http://localhost:8081)
20    #[serde(default = "default_eruka_url")]
21    pub url: String,
22    /// API key for authentication (optional, depends on Eruka auth setup)
23    #[serde(default)]
24    pub api_key: Option<String>,
25    /// Max tokens for core memory injection
26    #[serde(default = "default_core_max_tokens")]
27    pub core_max_tokens: usize,
28}
29
30fn default_eruka_url() -> String {
31    "http://localhost:8081".into()
32}
33
34fn default_core_max_tokens() -> usize {
35    500
36}
37
38impl Default for ErukaConfig {
39    fn default() -> Self {
40        Self {
41            enabled: false,
42            url: default_eruka_url(),
43            api_key: None,
44            core_max_tokens: default_core_max_tokens(),
45        }
46    }
47}
48
49/// Eruka HTTP client
50pub struct ErukaClient {
51    config: ErukaConfig,
52    http: reqwest::Client,
53}
54
55/// Search result from Eruka
56#[derive(Debug, Deserialize)]
57pub struct SearchResult {
58    pub content: Option<String>,
59    pub field_name: Option<String>,
60    pub score: Option<f64>,
61}
62
63/// Context response from Eruka
64#[derive(Debug, Deserialize)]
65pub struct ContextResponse {
66    pub fields: Option<Vec<ContextField>>,
67}
68
69#[derive(Debug, Deserialize)]
70pub struct ContextField {
71    pub name: Option<String>,
72    pub value: Option<String>,
73    pub category: Option<String>,
74}
75
76impl ErukaClient {
77    /// Create a new Eruka client
78    pub fn new(config: ErukaConfig) -> Self {
79        Self {
80            config,
81            http: reqwest::Client::new(),
82        }
83    }
84
85    /// Check if Eruka integration is enabled
86    pub fn is_enabled(&self) -> bool {
87        self.config.enabled
88    }
89
90    /// Fetch core memory from Eruka and build a system message
91    pub async fn fetch_core_memory(&self) -> Result<Option<String>> {
92        if !self.config.enabled {
93            return Ok(None);
94        }
95
96        let url = format!("{}/api/v1/context", self.config.url);
97        let mut req = self.http.get(&url);
98        // Service-to-service auth via X-Service-Key + X-Workspace-Id
99        if let Some(key) = &self.config.api_key {
100            req = req
101                .header("X-Service-Key", key.as_str())
102                .header("X-Workspace-Id", "pawan");
103        }
104
105        let resp = req.send().await.map_err(|e| {
106            tracing::warn!("Eruka context fetch failed: {}", e);
107            PawanError::Agent(format!("Eruka: {}", e))
108        })?;
109
110        if !resp.status().is_success() {
111            tracing::warn!("Eruka returned {}", resp.status());
112            return Ok(None);
113        }
114
115        let body = resp
116            .text()
117            .await
118            .map_err(|e| PawanError::Agent(format!("Eruka body: {}", e)))?;
119
120        // Parse context fields and build memory string
121        if let Ok(ctx) = serde_json::from_str::<ContextResponse>(&body) {
122            if let Some(fields) = ctx.fields {
123                let memory: Vec<String> = fields
124                    .iter()
125                    .filter_map(|f| {
126                        let name = f.name.as_deref()?;
127                        let value = f.value.as_deref()?;
128                        Some(format!("{}: {}", name, value))
129                    })
130                    .collect();
131
132                if memory.is_empty() {
133                    return Ok(None);
134                }
135
136                // Truncate to core_max_tokens worth of chars (~4 chars/token)
137                let max_chars = self.config.core_max_tokens * 4;
138                let joined = memory.join("\n");
139                let truncated: String = joined.chars().take(max_chars).collect();
140
141                return Ok(Some(format!(
142                    "[Eruka Core Memory]\n{}\n[End Core Memory]",
143                    truncated
144                )));
145            }
146        }
147
148        // Fallback: try to use raw body if it's text
149        if !body.is_empty() && body.len() < self.config.core_max_tokens * 4 {
150            return Ok(Some(format!(
151                "[Eruka Core Memory]\n{}\n[End Core Memory]",
152                body
153            )));
154        }
155
156        Ok(None)
157    }
158
159    /// Inject core memory into conversation history as a system message
160    pub async fn inject_core_memory(&self, history: &mut Vec<Message>) -> Result<()> {
161        if !self.config.enabled {
162            return Ok(());
163        }
164
165        if let Some(memory) = self.fetch_core_memory().await? {
166            // Check if we already injected (avoid duplicates across iterations)
167            let already_injected = history
168                .iter()
169                .any(|m| m.role == Role::System && m.content.contains("[Eruka Core Memory]"));
170
171            if !already_injected {
172                history.insert(
173                    0,
174                    Message {
175                        role: Role::System,
176                        content: memory,
177                        tool_calls: vec![],
178                        tool_result: None,
179                    },
180                );
181                tracing::info!("Injected Eruka core memory into context");
182            }
183        }
184
185        Ok(())
186    }
187
188    /// Search Eruka's archival memory for context relevant to a query
189    pub async fn search_archival(&self, query: &str) -> Result<Vec<String>> {
190        if !self.config.enabled {
191            return Ok(vec![]);
192        }
193
194        let url = format!("{}/api/v1/context/search", self.config.url);
195        let mut req = self
196            .http
197            .post(&url)
198            .json(&serde_json::json!({"query": query, "limit": 5}));
199        if let Some(key) = &self.config.api_key {
200            req = req
201                .header("X-Service-Key", key.as_str())
202                .header("X-Workspace-Id", "pawan");
203        }
204
205        let resp = req.send().await.map_err(|e| {
206            tracing::warn!("Eruka search failed: {}", e);
207            PawanError::Agent(format!("Eruka search: {}", e))
208        })?;
209
210        if !resp.status().is_success() {
211            return Ok(vec![]);
212        }
213
214        let body = resp.text().await.unwrap_or_default();
215        if let Ok(results) = serde_json::from_str::<Vec<SearchResult>>(&body) {
216            Ok(results.into_iter().filter_map(|r| r.content).collect())
217        } else {
218            Ok(vec![])
219        }
220    }
221
222    /// Write a context field to Eruka. Low-level helper shared by
223    /// `sync_turn`, `on_pre_compress`, and `archive_session`.
224    ///
225    /// Returns `Ok(false)` if Eruka is disabled or the write failed
226    /// (non-fatal — Eruka integration never breaks the agent loop).
227    pub async fn write_context(
228        &self,
229        path: &str,
230        value: &str,
231        source: &str,
232        confidence: f64,
233    ) -> Result<bool> {
234        if !self.config.enabled {
235            return Ok(false);
236        }
237        let url = format!("{}/api/v1/context", self.config.url);
238        let mut req = self.http.post(&url).json(&serde_json::json!({
239            "path": path,
240            "value": value,
241            "source": source,
242            "confidence": confidence,
243        }));
244        if let Some(key) = &self.config.api_key {
245            req = req
246                .header("X-Service-Key", key.as_str())
247                .header("X-Workspace-Id", "pawan");
248        }
249        match req.send().await {
250            Ok(resp) if resp.status().is_success() => Ok(true),
251            Ok(resp) => {
252                tracing::warn!("Eruka write_context returned {}", resp.status());
253                Ok(false)
254            }
255            Err(e) => {
256                tracing::warn!("Eruka write_context failed (non-fatal): {}", e);
257                Ok(false)
258            }
259        }
260    }
261
262    /// Persist a completed conversation turn. Lifecycle hook — call at the
263    /// end of each agent turn to build up historical context.
264    ///
265    /// Mirrors eruka-mcp's `eruka_sync_turn`. Writes to
266    /// `operations/turns/{session_id}` with confidence 0.9.
267    pub async fn sync_turn(
268        &self,
269        user_message: &str,
270        assistant_message: &str,
271        session_id: &str,
272    ) -> Result<bool> {
273        if !self.config.enabled {
274            return Ok(false);
275        }
276        // Match eruka-mcp's 500-char cap per side to keep writes bounded.
277        let user_trim: String = user_message.chars().take(500).collect();
278        let asst_trim: String = assistant_message.chars().take(500).collect();
279        let path = format!("operations/turns/{session_id}");
280        let value = format!("USER: {user_trim} | ASSISTANT: {asst_trim}");
281        self.write_context(&path, &value, "agent_inference", 0.9)
282            .await
283    }
284
285    /// Save a summary of messages about to be compressed/truncated.
286    /// Lifecycle hook — call before context window compression so the
287    /// important facts survive the truncation.
288    ///
289    /// Mirrors eruka-mcp's `eruka_on_pre_compress`. Writes to
290    /// `operations/compressed_insights/{session_id}` with confidence 0.8.
291    pub async fn on_pre_compress(&self, messages: &str, session_id: &str) -> Result<bool> {
292        if !self.config.enabled {
293            return Ok(false);
294        }
295        let path = format!("operations/compressed_insights/{session_id}");
296        let summary = if messages.len() > 2000 {
297            format!(
298                "{}...(truncated {} chars)",
299                &messages[..2000],
300                messages.len() - 2000
301            )
302        } else {
303            messages.to_string()
304        };
305        self.write_context(&path, &summary, "agent_inference", 0.8)
306            .await
307    }
308
309    /// Prefetch relevant context at the start of a turn. Combines semantic
310    /// search with compressed context for optimal recall.
311    ///
312    /// Mirrors eruka-mcp's `eruka_prefetch`. Returns the prefetched context
313    /// as a formatted string, or `None` if disabled / no results.
314    pub async fn prefetch(&self, query: &str, max_tokens: usize) -> Result<Option<String>> {
315        if !self.config.enabled {
316            return Ok(None);
317        }
318
319        // Semantic search — top 5 results
320        let search_url = format!("{}/api/v1/context/search", self.config.url);
321        let mut req = self.http.post(&search_url).json(&serde_json::json!({
322            "query": query,
323            "limit": 5,
324        }));
325        if let Some(key) = &self.config.api_key {
326            req = req
327                .header("X-Service-Key", key.as_str())
328                .header("X-Workspace-Id", "pawan");
329        }
330        let search_text = match req.send().await {
331            Ok(resp) if resp.status().is_success() => resp.text().await.unwrap_or_default(),
332            Ok(resp) => {
333                tracing::warn!("Eruka prefetch search returned {}", resp.status());
334                String::new()
335            }
336            Err(e) => {
337                tracing::warn!("Eruka prefetch search failed: {}", e);
338                return Ok(None);
339            }
340        };
341
342        // Compressed context for general task relevance
343        let compress_url = format!("{}/api/v1/compress", self.config.url);
344        let mut req = self.http.post(&compress_url).json(&serde_json::json!({
345            "task_type": "general",
346            "max_tokens": max_tokens,
347        }));
348        if let Some(key) = &self.config.api_key {
349            req = req
350                .header("X-Service-Key", key.as_str())
351                .header("X-Workspace-Id", "pawan");
352        }
353        let compress_text = match req.send().await {
354            Ok(resp) if resp.status().is_success() => resp.text().await.unwrap_or_default(),
355            Ok(resp) => {
356                tracing::warn!("Eruka prefetch compress returned {}", resp.status());
357                String::new()
358            }
359            Err(e) => {
360                tracing::warn!("Eruka prefetch compress failed: {}", e);
361                String::new()
362            }
363        };
364
365        if search_text.is_empty() && compress_text.is_empty() {
366            return Ok(None);
367        }
368
369        Ok(Some(format!(
370            "[Eruka Prefetch for: {query}]\nSearch results: {search_text}\nCompressed: {compress_text}\n[End Prefetch]"
371        )))
372    }
373
374    /// Fetch cached context with a hash for diff-based caching.
375    /// Returns `(content, hash)` so the caller can skip re-reads when the
376    /// hash is unchanged (cachebro pattern — 20-30% token savings).
377    ///
378    /// Mirrors eruka-mcp's `eruka_get_context_cached`.
379    pub async fn get_context_cached(
380        &self,
381        path: &str,
382        _session_id: &str,
383    ) -> Result<Option<(String, String)>> {
384        if !self.config.enabled {
385            return Ok(None);
386        }
387
388        let url = format!(
389            "{}/api/v1/context?path={}&include_metadata=false",
390            self.config.url, path
391        );
392        let mut req = self.http.get(&url);
393        if let Some(key) = &self.config.api_key {
394            req = req
395                .header("X-Service-Key", key.as_str())
396                .header("X-Workspace-Id", "pawan");
397        }
398
399        let resp = match req.send().await {
400            Ok(r) if r.status().is_success() => r,
401            Ok(r) => {
402                tracing::warn!("Eruka get_context_cached returned {}", r.status());
403                return Ok(None);
404            }
405            Err(e) => {
406                tracing::warn!("Eruka get_context_cached failed: {}", e);
407                return Ok(None);
408            }
409        };
410
411        let body = resp.text().await.unwrap_or_default();
412        if body.is_empty() {
413            return Ok(None);
414        }
415
416        // SHA-256-ish hash using std DefaultHasher (first 16 hex chars).
417        // Matches eruka-mcp's hashing approach — stable across calls.
418        use std::collections::hash_map::DefaultHasher;
419        use std::hash::{Hash, Hasher};
420        let mut hasher = DefaultHasher::new();
421        body.hash(&mut hasher);
422        let hash = format!("{:016x}", hasher.finish());
423
424        Ok(Some((body, hash)))
425    }
426
427    /// Export all context as a portable JSON bundle (context core).
428    /// Use for backup, agent-to-agent transfer, or offline use.
429    ///
430    /// Mirrors eruka-mcp's `eruka_export_context`. Pass category="*" for
431    /// full export, or a specific category like "identity" / "products".
432    pub async fn export_context(
433        &self,
434        category: &str,
435        include_metadata: bool,
436    ) -> Result<Option<serde_json::Value>> {
437        if !self.config.enabled {
438            return Ok(None);
439        }
440
441        let url = format!(
442            "{}/api/v1/context?path={}&include_metadata={}",
443            self.config.url, category, include_metadata
444        );
445        let mut req = self.http.get(&url);
446        if let Some(key) = &self.config.api_key {
447            req = req
448                .header("X-Service-Key", key.as_str())
449                .header("X-Workspace-Id", "pawan");
450        }
451
452        let resp = match req.send().await {
453            Ok(r) if r.status().is_success() => r,
454            Ok(r) => {
455                tracing::warn!("Eruka export_context returned {}", r.status());
456                return Ok(None);
457            }
458            Err(e) => {
459                tracing::warn!("Eruka export_context failed: {}", e);
460                return Ok(None);
461            }
462        };
463
464        let body = resp.text().await.unwrap_or_default();
465        let context_data: serde_json::Value =
466            serde_json::from_str(&body).unwrap_or(serde_json::Value::Null);
467
468        Ok(Some(serde_json::json!({
469            "export_format": "eruka_context_core_v1",
470            "category": category,
471            "data": context_data,
472            "exported_at": chrono::Utc::now().to_rfc3339(),
473            "instructions": "Import this bundle into another Eruka instance via eruka_write_context for each field.",
474        })))
475    }
476
477    /// Archive a completed session to Eruka's context store
478    pub async fn archive_session(&self, session: &Session) -> Result<()> {
479        if !self.config.enabled {
480            return Ok(());
481        }
482
483        // Build a summary of the session for archival
484        let user_messages: Vec<&str> = session
485            .messages
486            .iter()
487            .filter(|m| m.role == Role::User)
488            .map(|m| m.content.as_str())
489            .collect();
490
491        let assistant_messages: Vec<&str> = session
492            .messages
493            .iter()
494            .filter(|m| m.role == Role::Assistant)
495            .map(|m| m.content.as_str())
496            .collect();
497
498        if user_messages.is_empty() {
499            return Ok(());
500        }
501
502        let summary = format!(
503            "Session {} (model: {}, {} messages)\nUser topics: {}\nAssistant summary: {}",
504            session.id,
505            session.model,
506            session.messages.len(),
507            user_messages.join(" | "),
508            assistant_messages
509                .last()
510                .map(|s| {
511                    let trunc: String = s.chars().take(500).collect();
512                    trunc
513                })
514                .unwrap_or_default(),
515        );
516
517        let url = format!("{}/api/v1/context", self.config.url);
518        let mut req = self.http.post(&url).json(&serde_json::json!({
519            "path": format!("operations/sessions/{}", session.id),
520            "value": summary,
521            "source": "agent",
522        }));
523        if let Some(key) = &self.config.api_key {
524            req = req
525                .header("X-Service-Key", key.as_str())
526                .header("X-Workspace-Id", "pawan");
527        }
528
529        match req.send().await {
530            Ok(resp) => {
531                if resp.status().is_success() {
532                    tracing::info!("Archived session {} to Eruka", session.id);
533                } else {
534                    tracing::warn!("Eruka archive returned {}", resp.status());
535                }
536            }
537            Err(e) => {
538                tracing::warn!("Eruka archive failed (non-fatal): {}", e);
539            }
540        }
541
542        Ok(())
543    }
544}
545
546#[cfg(test)]
547mod tests {
548    use super::*;
549
550    #[test]
551    fn default_config_disabled() {
552        let config = ErukaConfig::default();
553        assert!(!config.enabled);
554        assert_eq!(config.url, "http://localhost:8081");
555        assert_eq!(config.core_max_tokens, 500);
556    }
557
558    #[test]
559    fn client_respects_enabled() {
560        let config = ErukaConfig::default();
561        let client = ErukaClient::new(config);
562        assert!(!client.is_enabled());
563    }
564
565    #[tokio::test]
566    async fn disabled_client_noops() {
567        let client = ErukaClient::new(ErukaConfig::default());
568        let mut history = vec![];
569        client.inject_core_memory(&mut history).await.unwrap();
570        assert!(history.is_empty());
571
572        let results = client.search_archival("test").await.unwrap();
573        assert!(results.is_empty());
574    }
575
576    #[test]
577    fn config_toml_parsing() {
578        let toml = r#"
579enabled = true
580url = "http://eruka.example.com:9090"
581api_key = "secret-key"
582core_max_tokens = 1000
583"#;
584        let config: ErukaConfig = toml::from_str(toml).expect("should parse");
585        assert!(config.enabled);
586        assert_eq!(config.url, "http://eruka.example.com:9090");
587        assert_eq!(config.api_key, Some("secret-key".into()));
588        assert_eq!(config.core_max_tokens, 1000);
589    }
590
591    #[test]
592    fn config_toml_defaults() {
593        let toml = "enabled = true\n";
594        let config: ErukaConfig = toml::from_str(toml).expect("should parse");
595        assert!(config.enabled);
596        assert_eq!(config.url, "http://localhost:8081");
597        assert_eq!(config.core_max_tokens, 500);
598        assert_eq!(config.api_key, None);
599    }
600
601    #[test]
602    fn context_response_deserialization() {
603        let json = r#"{"fields":[{"name":"project","value":"pawan","category":"core"},{"name":"role","value":"coding agent"}]}"#;
604        let ctx: ContextResponse = serde_json::from_str(json).unwrap();
605        let fields = ctx.fields.unwrap();
606        assert_eq!(fields.len(), 2);
607        assert_eq!(fields[0].name.as_deref(), Some("project"));
608        assert_eq!(fields[0].value.as_deref(), Some("pawan"));
609        assert_eq!(fields[0].category.as_deref(), Some("core"));
610        assert_eq!(fields[1].category, None);
611    }
612
613    #[test]
614    fn context_response_empty_fields() {
615        let json = r#"{"fields":[]}"#;
616        let ctx: ContextResponse = serde_json::from_str(json).unwrap();
617        assert!(ctx.fields.unwrap().is_empty());
618    }
619
620    #[test]
621    fn context_response_missing_fields() {
622        let json = r#"{}"#;
623        let ctx: ContextResponse = serde_json::from_str(json).unwrap();
624        assert!(ctx.fields.is_none());
625    }
626
627    #[test]
628    fn search_result_deserialization() {
629        let json = r#"[{"content":"relevant info","field_name":"notes","score":0.95},{"content":null,"score":0.5}]"#;
630        let results: Vec<SearchResult> = serde_json::from_str(json).unwrap();
631        assert_eq!(results.len(), 2);
632        assert_eq!(results[0].content.as_deref(), Some("relevant info"));
633        assert_eq!(results[0].score, Some(0.95));
634        assert!(results[1].content.is_none());
635    }
636
637    #[tokio::test]
638    async fn disabled_archive_noops() {
639        use crate::agent::session::Session;
640        let client = ErukaClient::new(ErukaConfig::default());
641        let session = Session {
642            notes: String::new(),
643            parent_id: None,
644            root_id: None,
645            branch_label: None,
646            branch_depth: 0,
647            labels: vec![],
648            id: "test-123".into(),
649            model: "test-model".into(),
650            messages: vec![],
651            created_at: "2026-04-09T00:00:00Z".into(),
652            updated_at: "2026-04-09T00:00:00Z".into(),
653            total_tokens: 0,
654            iteration_count: 0,
655            tags: Vec::new(),
656        };
657        // Should succeed without making HTTP calls
658        client.archive_session(&session).await.unwrap();
659    }
660
661    #[tokio::test]
662    async fn inject_dedup_prevents_double_injection() {
663        // Simulate a history that already has eruka memory injected
664        let history = [
665            Message {
666                role: Role::System,
667                content: "[Eruka Core Memory]\nproject: pawan\n[End Core Memory]".into(),
668                tool_calls: vec![],
669                tool_result: None,
670            },
671            Message {
672                role: Role::User,
673                content: "hello".into(),
674                tool_calls: vec![],
675                tool_result: None,
676            },
677        ];
678        // Even if enabled, inject should detect the existing marker and skip
679        // (We can't actually fetch from eruka in tests, but we test the dedup check)
680        let already = history
681            .iter()
682            .any(|m| m.role == Role::System && m.content.contains("[Eruka Core Memory]"));
683        assert!(already, "Should detect existing injection");
684    }
685
686    #[test]
687    fn default_config_has_no_api_key() {
688        // Regression: default ErukaConfig must have api_key = None so
689        // unconfigured pawan never sends bogus auth headers.
690        let config = ErukaConfig::default();
691        assert_eq!(config.api_key, None, "default api_key must be None");
692    }
693
694    #[test]
695    fn config_partial_override_keeps_defaults() {
696        // Providing only `enabled = true` must keep url/api_key/core_max_tokens
697        // at their default values via serde defaults. This guards against
698        // removing the #[serde(default)] attributes by accident.
699        let toml = "enabled = true\n";
700        let config: ErukaConfig = toml::from_str(toml).expect("should parse");
701        assert!(config.enabled);
702        assert_eq!(
703            config.url, "http://localhost:8081",
704            "url default must apply"
705        );
706        assert_eq!(
707            config.core_max_tokens, 500,
708            "core_max_tokens default must apply"
709        );
710        assert_eq!(config.api_key, None, "api_key default must apply");
711    }
712
713    #[test]
714    fn search_result_deserialize_with_all_null_fields() {
715        // Eruka can return entries with every optional field null — the
716        // SearchResult struct must survive without erroring so archive
717        // traversal is robust to partial data.
718        let json = r#"[{"content":null,"field_name":null,"score":null}]"#;
719        let results: Vec<SearchResult> = serde_json::from_str(json).unwrap();
720        assert_eq!(results.len(), 1);
721        assert!(results[0].content.is_none());
722        assert!(results[0].field_name.is_none());
723        assert!(results[0].score.is_none());
724    }
725
726    #[test]
727    fn context_field_deserialize_without_category() {
728        // A field with only name+value (no category) must still deserialize —
729        // category is optional because Eruka doesn't always return it.
730        let json = r#"{"name":"model","value":"qwen3.5-122b"}"#;
731        let field: ContextField = serde_json::from_str(json).unwrap();
732        assert_eq!(field.name.as_deref(), Some("model"));
733        assert_eq!(field.value.as_deref(), Some("qwen3.5-122b"));
734        assert!(field.category.is_none(), "category must default to None");
735    }
736
737    // ─────────────────────────────────────────────────────────────────
738    // Regression: sync_turn must handle long messages (task #80)
739    // ─────────────────────────────────────────────────────────────────
740
741    #[tokio::test]
742    async fn sync_turn_caps_long_messages_at_500_chars_each() {
743        // Regression: the 500-char cap must handle UTF-8 boundaries correctly.
744        // Use a disabled client — we only care about the pre-cap panic path.
745        let client = ErukaClient::new(ErukaConfig::default());
746        let long_user = "a".repeat(1200);
747        let long_asst = "b".repeat(1200);
748        // Must not panic even though each message is 1200 chars.
749        let result = client
750            .sync_turn(&long_user, &long_asst, "session-long")
751            .await;
752        assert!(result.is_ok(), "long messages must not panic");
753    }
754
755    #[tokio::test]
756    async fn archive_enabled_with_no_user_messages_short_circuits() {
757        // When archive_session() is called on an enabled client with a
758        // session that has no user messages, it must early-return Ok(())
759        // BEFORE attempting any HTTP call. Use an unreachable URL so the
760        // test fails if it actually tries to hit the network.
761        let config = ErukaConfig {
762            enabled: true,
763            url: "http://127.0.0.1:1".into(), // unreachable port
764            api_key: None,
765            core_max_tokens: 500,
766        };
767        let client = ErukaClient::new(config);
768        let session = Session {
769            notes: String::new(),
770            parent_id: None,
771            root_id: None,
772            branch_label: None,
773            branch_depth: 0,
774            labels: vec![],
775            id: "assistant-only".into(),
776            model: "m".into(),
777            messages: vec![Message {
778                role: Role::Assistant,
779                content: "hi".into(),
780                tool_calls: vec![],
781                tool_result: None,
782            }],
783            created_at: "2026-04-10T00:00:00Z".into(),
784            updated_at: "2026-04-10T00:00:00Z".into(),
785            total_tokens: 0,
786            iteration_count: 0,
787            tags: Vec::new(),
788        };
789        // If this ever tries to connect to 127.0.0.1:1 it'll take >50ms and
790        // likely Err — we expect it to return Ok instantly via the early
791        // return on empty user_messages.
792        let result = tokio::time::timeout(
793            std::time::Duration::from_millis(500),
794            client.archive_session(&session),
795        )
796        .await
797        .expect("archive must not hang — empty user messages should short-circuit");
798        assert!(result.is_ok());
799    }
800
801    // ── New lifecycle/caching/export method tests (disabled path) ──────────
802
803    #[tokio::test]
804    async fn write_context_disabled_returns_false() {
805        let client = ErukaClient::new(ErukaConfig::default());
806        let ok = client
807            .write_context("identity/name", "pawan", "test", 1.0)
808            .await
809            .unwrap();
810        assert!(
811            !ok,
812            "disabled client must return false without calling network"
813        );
814    }
815
816    #[tokio::test]
817    async fn sync_turn_disabled_returns_false() {
818        let client = ErukaClient::new(ErukaConfig::default());
819        let ok = client.sync_turn("hello", "world", "ses_abc").await.unwrap();
820        assert!(!ok, "disabled client must short-circuit");
821    }
822
823    #[tokio::test]
824    async fn on_pre_compress_disabled_returns_false() {
825        let client = ErukaClient::new(ErukaConfig::default());
826        let ok = client
827            .on_pre_compress("some messages", "ses_abc")
828            .await
829            .unwrap();
830        assert!(!ok, "disabled client must short-circuit");
831    }
832
833    #[tokio::test]
834    async fn prefetch_disabled_returns_none() {
835        let client = ErukaClient::new(ErukaConfig::default());
836        let result = client.prefetch("test query", 1000).await.unwrap();
837        assert!(result.is_none(), "disabled client must return None");
838    }
839
840    #[tokio::test]
841    async fn get_context_cached_disabled_returns_none() {
842        let client = ErukaClient::new(ErukaConfig::default());
843        let result = client
844            .get_context_cached("identity/*", "ses_abc")
845            .await
846            .unwrap();
847        assert!(result.is_none(), "disabled client must return None");
848    }
849
850    #[tokio::test]
851    async fn export_context_disabled_returns_none() {
852        let client = ErukaClient::new(ErukaConfig::default());
853        let result = client.export_context("*", true).await.unwrap();
854        assert!(result.is_none(), "disabled client must return None");
855    }
856}