Skip to main content

nexo_core/agent/
session_logs_tool.rs

1use super::context::AgentContext;
2use super::tool_registry::ToolHandler;
3use super::transcripts::{SessionHeader, TranscriptEntry, TranscriptLine, TranscriptWriter};
4use super::transcripts_index::TranscriptsIndex;
5use async_trait::async_trait;
6use nexo_llm::ToolDef;
7use serde_json::{json, Value};
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10use uuid::Uuid;
11const DEFAULT_LIMIT: usize = 50;
12const MAX_LIMIT: usize = 500;
13const DEFAULT_MAX_CHARS: usize = 200;
14/// LLM-facing tool that reads this agent's own JSONL transcripts. Useful for
15/// self-reflection ("what did I tell her yesterday?"), debugging ("what did
16/// the tool return in session X?"), and lightweight cross-session search.
17///
18/// The tool is scoped to `ctx.config.transcripts_dir` — it cannot see other
19/// agents' transcripts. Current-session access is allowed by default; other
20/// sessions are readable because they belong to the same agent owner.
21pub struct SessionLogsTool {
22    index: Option<Arc<TranscriptsIndex>>,
23}
24impl SessionLogsTool {
25    pub fn new() -> Self {
26        Self { index: None }
27    }
28    pub fn with_index(mut self, index: Arc<TranscriptsIndex>) -> Self {
29        self.index = Some(index);
30        self
31    }
32    pub fn tool_def() -> ToolDef {
33        ToolDef {
34            name: "session_logs".to_string(),
35            description: "Inspect this agent's stored session transcripts. Actions: \
36                list_sessions (summary of every recorded session), \
37                read_session (all lines of one session, capped), \
38                search (grep substring across sessions, case-insensitive), \
39                recent (last N entries of a session, default current)."
40                .to_string(),
41            parameters: json!({
42                "type": "object",
43                "properties": {
44                    "action": {
45                        "type": "string",
46                        "enum": ["list_sessions", "read_session", "search", "recent"]
47                    },
48                    "session_id": { "type": "string", "description": "UUID of the session. For read_session/recent; optional on recent (defaults to current)." },
49                    "query":      { "type": "string", "description": "Substring to match for search action (case-insensitive)." },
50                    "limit":      { "type": "integer", "minimum": 1, "maximum": MAX_LIMIT, "description": "Max entries/sessions to return." },
51                    "max_chars":  { "type": "integer", "minimum": 20, "maximum": 4000, "description": "Truncate each content preview at this length (default 200)." }
52                },
53                "required": ["action"]
54            }),
55        }
56    }
57}
58impl Default for SessionLogsTool {
59    fn default() -> Self {
60        Self::new()
61    }
62}
63#[async_trait]
64impl ToolHandler for SessionLogsTool {
65    async fn call(&self, ctx: &AgentContext, args: Value) -> anyhow::Result<Value> {
66        let action = args["action"].as_str().unwrap_or("").trim();
67        let transcripts_dir = ctx.config.transcripts_dir.trim();
68        if transcripts_dir.is_empty() {
69            return Ok(json!({
70                "ok": false,
71                "error": "transcripts_dir is not configured for this agent"
72            }));
73        }
74        let root = PathBuf::from(transcripts_dir);
75        let writer = TranscriptWriter::new(root.clone(), ctx.agent_id.clone());
76        match action {
77            "list_sessions" => {
78                let limit = optional_usize(&args, "limit")?
79                    .unwrap_or(DEFAULT_LIMIT)
80                    .min(MAX_LIMIT);
81                list_sessions(&writer, &root, limit).await
82            }
83            "read_session" => {
84                let id = required_uuid(&args, "session_id")?;
85                let limit = optional_usize(&args, "limit")?
86                    .unwrap_or(DEFAULT_LIMIT)
87                    .min(MAX_LIMIT);
88                let max_chars = optional_usize(&args, "max_chars")?
89                    .unwrap_or(DEFAULT_MAX_CHARS)
90                    .clamp(20, 4000);
91                read_session(&writer, id, limit, max_chars).await
92            }
93            "search" => {
94                let query = required_nonempty_string(&args, "query")?;
95                let limit = optional_usize(&args, "limit")?
96                    .unwrap_or(DEFAULT_LIMIT)
97                    .min(MAX_LIMIT);
98                let max_chars = optional_usize(&args, "max_chars")?
99                    .unwrap_or(DEFAULT_MAX_CHARS)
100                    .clamp(20, 4000);
101                if let Some(index) = self.index.as_ref() {
102                    search_via_fts(index, &ctx.agent_id, &query, limit).await
103                } else {
104                    search_sessions(&writer, &root, &query, limit, max_chars).await
105                }
106            }
107            "recent" => {
108                let id = match optional_string(&args, "session_id") {
109                    Some(s) => Uuid::parse_str(&s)
110                        .map_err(|e| anyhow::anyhow!("`session_id` is not a valid UUID: {e}"))?,
111                    None => ctx.session_id.ok_or_else(|| {
112                        anyhow::anyhow!(
113                            "recent action requires either `session_id` or a session-scoped context"
114                        )
115                    })?,
116                };
117                let limit = optional_usize(&args, "limit")?.unwrap_or(10).min(MAX_LIMIT);
118                let max_chars = optional_usize(&args, "max_chars")?
119                    .unwrap_or(DEFAULT_MAX_CHARS)
120                    .clamp(20, 4000);
121                let lines = writer.read_session(id).await?;
122                let entries = entries_only(lines);
123                let slice = tail(entries, limit);
124                Ok(json!({
125                    "ok": true,
126                    "session_id": id.to_string(),
127                    "count": slice.len(),
128                    "entries": render_entries(&slice, max_chars),
129                }))
130            }
131            other => Err(anyhow::anyhow!(
132                "unknown action `{other}`; expected list_sessions|read_session|search|recent"
133            )),
134        }
135    }
136}
137async fn list_sessions(
138    writer: &TranscriptWriter,
139    root: &Path,
140    limit: usize,
141) -> anyhow::Result<Value> {
142    let mut rows: Vec<Value> = Vec::new();
143    let mut entries = tokio::fs::read_dir(root)
144        .await
145        .map_err(|e| anyhow::anyhow!("cannot read transcripts_dir `{}`: {e}", root.display()))?;
146    let mut files: Vec<PathBuf> = Vec::new();
147    while let Some(entry) = entries.next_entry().await? {
148        let path = entry.path();
149        if path.extension().and_then(|s| s.to_str()) == Some("jsonl") {
150            files.push(path);
151        }
152    }
153    // Most-recently-modified first.
154    files.sort_by_key(|p| {
155        std::fs::metadata(p)
156            .and_then(|m| m.modified())
157            .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
158    });
159    files.reverse();
160    files.truncate(limit);
161    for path in &files {
162        let stem = path
163            .file_stem()
164            .and_then(|s| s.to_str())
165            .unwrap_or_default();
166        let Ok(uuid) = Uuid::parse_str(stem) else {
167            continue;
168        };
169        let lines = writer.read_session(uuid).await.unwrap_or_default();
170        let header = lines.iter().find_map(|l| match l {
171            TranscriptLine::Session(h) => Some(h.clone()),
172            _ => None,
173        });
174        let entry_count = lines
175            .iter()
176            .filter(|l| matches!(l, TranscriptLine::Entry(_)))
177            .count();
178        let first_ts = lines.iter().find_map(|l| match l {
179            TranscriptLine::Entry(e) => Some(e.timestamp.to_rfc3339()),
180            _ => None,
181        });
182        let last_ts = lines.iter().rev().find_map(|l| match l {
183            TranscriptLine::Entry(e) => Some(e.timestamp.to_rfc3339()),
184            _ => None,
185        });
186        rows.push(summary_row(&header, uuid, entry_count, first_ts, last_ts));
187    }
188    Ok(json!({
189        "ok": true,
190        "transcripts_dir": root.display().to_string(),
191        "count": rows.len(),
192        "sessions": rows,
193    }))
194}
195async fn read_session(
196    writer: &TranscriptWriter,
197    session_id: Uuid,
198    limit: usize,
199    max_chars: usize,
200) -> anyhow::Result<Value> {
201    let lines = writer.read_session(session_id).await?;
202    if lines.is_empty() {
203        return Ok(json!({
204            "ok": false,
205            "error": "session_not_found",
206            "session_id": session_id.to_string()
207        }));
208    }
209    let header = lines.iter().find_map(|l| match l {
210        TranscriptLine::Session(h) => Some(h.clone()),
211        _ => None,
212    });
213    let entries = entries_only(lines);
214    let total = entries.len();
215    let truncated = total > limit;
216    let slice: Vec<TranscriptEntry> = entries.into_iter().take(limit).collect();
217    Ok(json!({
218        "ok": true,
219        "session_id": session_id.to_string(),
220        "header": header.map(|h| json!({
221            "agent_id": h.agent_id,
222            "timestamp": h.timestamp.to_rfc3339(),
223            "source_plugin": h.source_plugin,
224            "version": h.version,
225        })),
226        "total_entries": total,
227        "returned": slice.len(),
228        "truncated": truncated,
229        "entries": render_entries(&slice, max_chars),
230    }))
231}
232async fn search_via_fts(
233    index: &TranscriptsIndex,
234    agent_id: &str,
235    query: &str,
236    limit: usize,
237) -> anyhow::Result<Value> {
238    let hits = index.search(agent_id, query, limit).await?;
239    let rows: Vec<Value> = hits
240        .into_iter()
241        .map(|h| {
242            let ts = chrono::DateTime::<chrono::Utc>::from_timestamp(h.timestamp_unix, 0)
243                .map(|d| d.to_rfc3339())
244                .unwrap_or_default();
245            json!({
246                "session_id": h.session_id.to_string(),
247                "timestamp": ts,
248                "role": h.role,
249                "source_plugin": h.source_plugin,
250                "preview": h.snippet,
251            })
252        })
253        .collect();
254    Ok(json!({
255        "ok": true,
256        "query": query,
257        "backend": "fts5",
258        "count": rows.len(),
259        "hits": rows,
260    }))
261}
262
263async fn search_sessions(
264    writer: &TranscriptWriter,
265    root: &Path,
266    query: &str,
267    limit: usize,
268    max_chars: usize,
269) -> anyhow::Result<Value> {
270    let needle = query.to_lowercase();
271    let mut hits: Vec<Value> = Vec::new();
272    let mut entries = tokio::fs::read_dir(root)
273        .await
274        .map_err(|e| anyhow::anyhow!("cannot read transcripts_dir `{}`: {e}", root.display()))?;
275    let mut files: Vec<PathBuf> = Vec::new();
276    while let Some(entry) = entries.next_entry().await? {
277        let p = entry.path();
278        if p.extension().and_then(|s| s.to_str()) == Some("jsonl") {
279            files.push(p);
280        }
281    }
282    files.sort();
283    'outer: for path in &files {
284        let stem = path
285            .file_stem()
286            .and_then(|s| s.to_str())
287            .unwrap_or_default();
288        let Ok(uuid) = Uuid::parse_str(stem) else {
289            continue;
290        };
291        let lines = writer.read_session(uuid).await.unwrap_or_default();
292        for line in &lines {
293            let TranscriptLine::Entry(e) = line else {
294                continue;
295            };
296            if e.content.to_lowercase().contains(&needle) {
297                hits.push(json!({
298                    "session_id": uuid.to_string(),
299                    "timestamp": e.timestamp.to_rfc3339(),
300                    "role": format!("{:?}", e.role).to_lowercase(),
301                    "source_plugin": e.source_plugin,
302                    "preview": truncate_chars(&e.content, max_chars),
303                }));
304                if hits.len() >= limit {
305                    break 'outer;
306                }
307            }
308        }
309    }
310    Ok(json!({
311        "ok": true,
312        "query": query,
313        "count": hits.len(),
314        "hits": hits,
315    }))
316}
317fn summary_row(
318    header: &Option<SessionHeader>,
319    id: Uuid,
320    entry_count: usize,
321    first_ts: Option<String>,
322    last_ts: Option<String>,
323) -> Value {
324    json!({
325        "session_id": id.to_string(),
326        "agent_id": header.as_ref().map(|h| h.agent_id.clone()),
327        "source_plugin": header.as_ref().map(|h| h.source_plugin.clone()),
328        "entry_count": entry_count,
329        "first_timestamp": first_ts,
330        "last_timestamp": last_ts,
331    })
332}
333fn entries_only(lines: Vec<TranscriptLine>) -> Vec<TranscriptEntry> {
334    lines
335        .into_iter()
336        .filter_map(|l| match l {
337            TranscriptLine::Entry(e) => Some(e),
338            _ => None,
339        })
340        .collect()
341}
342fn tail(mut entries: Vec<TranscriptEntry>, n: usize) -> Vec<TranscriptEntry> {
343    if entries.len() <= n {
344        return entries;
345    }
346    let drop = entries.len() - n;
347    entries.drain(..drop);
348    entries
349}
350fn render_entries(entries: &[TranscriptEntry], max_chars: usize) -> Value {
351    let out: Vec<Value> = entries
352        .iter()
353        .map(|e| {
354            json!({
355                "timestamp": e.timestamp.to_rfc3339(),
356                "role": format!("{:?}", e.role).to_lowercase(),
357                "source_plugin": e.source_plugin,
358                "message_id": e.message_id.map(|u| u.to_string()),
359                "sender_id": e.sender_id,
360                "content": truncate_chars(&e.content, max_chars),
361                "truncated": e.content.chars().count() > max_chars,
362            })
363        })
364        .collect();
365    Value::Array(out)
366}
367fn truncate_chars(s: &str, max: usize) -> String {
368    let total = s.chars().count();
369    if total <= max {
370        s.to_string()
371    } else {
372        let mut t: String = s.chars().take(max).collect();
373        t.push('…');
374        t
375    }
376}
377fn required_nonempty_string(args: &Value, key: &str) -> anyhow::Result<String> {
378    let s = args
379        .get(key)
380        .and_then(|v| v.as_str())
381        .ok_or_else(|| anyhow::anyhow!("missing or invalid `{key}`"))?
382        .trim()
383        .to_string();
384    if s.is_empty() {
385        anyhow::bail!("`{key}` cannot be empty");
386    }
387    Ok(s)
388}
389fn optional_string(args: &Value, key: &str) -> Option<String> {
390    args.get(key)
391        .and_then(|v| v.as_str())
392        .map(|s| s.trim().to_string())
393        .filter(|s| !s.is_empty())
394}
395fn required_uuid(args: &Value, key: &str) -> anyhow::Result<Uuid> {
396    let s = required_nonempty_string(args, key)?;
397    Uuid::parse_str(&s).map_err(|e| anyhow::anyhow!("`{key}` is not a valid UUID: {e}"))
398}
399fn optional_usize(args: &Value, key: &str) -> anyhow::Result<Option<usize>> {
400    match args.get(key) {
401        None | Some(Value::Null) => Ok(None),
402        Some(v) => v
403            .as_u64()
404            .map(|n| Some(n as usize))
405            .ok_or_else(|| anyhow::anyhow!("`{key}` must be a positive integer")),
406    }
407}
408#[cfg(test)]
409mod tests {
410    use super::*;
411    use crate::session::SessionManager;
412    use chrono::Utc;
413    use nexo_broker::{AnyBroker, BrokerHandle};
414    use nexo_config::types::agents::{
415        AgentConfig, AgentRuntimeConfig, HeartbeatConfig, ModelConfig,
416    };
417    use std::sync::Arc;
418    use std::time::Duration;
419    async fn setup() -> (SessionLogsTool, AgentContext, PathBuf, Uuid) {
420        let dir = tempfile::tempdir().expect("tempdir").keep();
421        let broker = AnyBroker::local();
422        let _ = broker.subscribe("_").await;
423        let cfg = Arc::new(AgentConfig {
424            id: "kate".into(),
425            model: ModelConfig {
426                provider: "stub".into(),
427                model: "m1".into(),
428            },
429            plugins: vec![],
430            heartbeat: HeartbeatConfig::default(),
431            config: AgentRuntimeConfig::default(),
432            system_prompt: String::new(),
433            workspace: String::new(),
434            skills: vec![],
435            skills_dir: "./skills".into(),
436            skill_overrides: Default::default(),
437            transcripts_dir: dir.to_string_lossy().to_string(),
438            dreaming: Default::default(),
439            workspace_git: Default::default(),
440            tool_rate_limits: None,
441            tool_args_validation: None,
442            extra_docs: Vec::new(),
443            inbound_bindings: Vec::new(),
444            allowed_tools: Vec::new(),
445            sender_rate_limit: None,
446            allowed_delegates: Vec::new(),
447            accept_delegates_from: Vec::new(),
448            description: String::new(),
449            outbound_allowlist: Default::default(),
450            google_auth: None,
451            credentials: Default::default(),
452            link_understanding: serde_json::Value::Null,
453            web_search: serde_json::Value::Null,
454            pairing_policy: serde_json::Value::Null,
455            language: None,
456            context_optimization: None,
457            dispatch_policy: Default::default(),
458            plan_mode: Default::default(),
459            remote_triggers: Vec::new(),
460            lsp: nexo_config::types::lsp::LspPolicy::default(),
461            config_tool: nexo_config::types::config_tool::ConfigToolPolicy::default(),
462            team: nexo_config::types::team::TeamPolicy::default(),
463            proactive: Default::default(),
464            repl: Default::default(),
465            auto_dream: None,
466            assistant_mode: None,
467            away_summary: None,
468            brief: None,
469            channels: None,
470            auto_approve: false,
471            extract_memories: None,
472            event_subscribers: Vec::new(),
473            tenant_id: None,
474            extensions_config: std::collections::BTreeMap::new(),
475            active: true,
476        });
477        let sessions = Arc::new(SessionManager::new(Duration::from_secs(60), 20));
478        let sid = Uuid::new_v4();
479        let ctx = AgentContext::new("kate", cfg, broker, sessions).with_session_id(sid);
480        (SessionLogsTool::new(), ctx, dir, sid)
481    }
482    async fn write_some_entries(
483        root: &Path,
484        agent_id: &str,
485        session: Uuid,
486        msgs: &[(TranscriptRole, &str)],
487    ) {
488        let w = TranscriptWriter::new(root, agent_id);
489        for (role, content) in msgs {
490            let entry = TranscriptEntry {
491                timestamp: Utc::now(),
492                role: *role,
493                content: (*content).to_string(),
494                message_id: None,
495                source_plugin: "test".into(),
496                sender_id: Some("user-1".into()),
497            };
498            w.append_entry(session, entry).await.unwrap();
499        }
500    }
501    use super::super::transcripts::TranscriptRole;
502    #[tokio::test]
503    async fn list_sessions_returns_summaries() {
504        let (tool, ctx, dir, _) = setup().await;
505        let s1 = Uuid::new_v4();
506        let s2 = Uuid::new_v4();
507        write_some_entries(
508            &dir,
509            "kate",
510            s1,
511            &[
512                (TranscriptRole::User, "hola"),
513                (TranscriptRole::Assistant, "buenos dias"),
514            ],
515        )
516        .await;
517        write_some_entries(&dir, "kate", s2, &[(TranscriptRole::User, "que haces")]).await;
518        let out = tool
519            .call(&ctx, json!({ "action": "list_sessions" }))
520            .await
521            .unwrap();
522        assert_eq!(out["ok"], true);
523        assert_eq!(out["count"], 2);
524        let sessions = out["sessions"].as_array().unwrap();
525        assert!(sessions
526            .iter()
527            .any(|s| s["session_id"].as_str().unwrap() == s1.to_string()));
528        assert!(sessions
529            .iter()
530            .any(|s| s["session_id"].as_str().unwrap() == s2.to_string()));
531    }
532    #[tokio::test]
533    async fn read_session_returns_entries_in_order() {
534        let (tool, ctx, dir, _) = setup().await;
535        let s = Uuid::new_v4();
536        write_some_entries(
537            &dir,
538            "kate",
539            s,
540            &[
541                (TranscriptRole::User, "first"),
542                (TranscriptRole::Assistant, "second"),
543                (TranscriptRole::User, "third"),
544            ],
545        )
546        .await;
547        let out = tool
548            .call(
549                &ctx,
550                json!({ "action": "read_session", "session_id": s.to_string() }),
551            )
552            .await
553            .unwrap();
554        assert_eq!(out["ok"], true);
555        assert_eq!(out["total_entries"], 3);
556        let entries = out["entries"].as_array().unwrap();
557        assert_eq!(entries[0]["content"], "first");
558        assert_eq!(entries[1]["role"], "assistant");
559    }
560    #[tokio::test]
561    async fn read_session_missing_returns_error() {
562        let (tool, ctx, _dir, _) = setup().await;
563        let out = tool
564            .call(
565                &ctx,
566                json!({ "action": "read_session", "session_id": Uuid::new_v4().to_string() }),
567            )
568            .await
569            .unwrap();
570        assert_eq!(out["ok"], false);
571        assert_eq!(out["error"], "session_not_found");
572    }
573    #[tokio::test]
574    async fn search_finds_case_insensitive_substring() {
575        let (tool, ctx, dir, _) = setup().await;
576        let s = Uuid::new_v4();
577        write_some_entries(
578            &dir,
579            "kate",
580            s,
581            &[
582                (TranscriptRole::User, "cuéntame sobre el CÓDIGO de Kate"),
583                (TranscriptRole::Assistant, "no lo sé"),
584            ],
585        )
586        .await;
587        let out = tool
588            .call(&ctx, json!({ "action": "search", "query": "código" }))
589            .await
590            .unwrap();
591        assert_eq!(out["ok"], true);
592        assert_eq!(out["count"], 1);
593        assert_eq!(out["hits"][0]["session_id"], s.to_string());
594    }
595    #[tokio::test]
596    async fn recent_defaults_to_current_session() {
597        let (tool, ctx, dir, sid) = setup().await;
598        // Write entries into the current session.
599        write_some_entries(
600            &dir,
601            "kate",
602            sid,
603            &[
604                (TranscriptRole::User, "a"),
605                (TranscriptRole::Assistant, "b"),
606                (TranscriptRole::User, "c"),
607                (TranscriptRole::Assistant, "d"),
608            ],
609        )
610        .await;
611        let out = tool
612            .call(&ctx, json!({ "action": "recent", "limit": 2 }))
613            .await
614            .unwrap();
615        assert_eq!(out["count"], 2);
616        // tail(…, 2) → last two
617        let entries = out["entries"].as_array().unwrap();
618        assert_eq!(entries[0]["content"], "c");
619        assert_eq!(entries[1]["content"], "d");
620    }
621    #[tokio::test]
622    async fn truncates_long_content() {
623        let (tool, ctx, dir, sid) = setup().await;
624        let long = "a".repeat(1000);
625        write_some_entries(&dir, "kate", sid, &[(TranscriptRole::User, long.as_str())]).await;
626        let out = tool
627            .call(
628                &ctx,
629                json!({ "action": "read_session", "session_id": sid.to_string(), "max_chars": 50 }),
630            )
631            .await
632            .unwrap();
633        let entries = out["entries"].as_array().unwrap();
634        let content = entries[0]["content"].as_str().unwrap();
635        assert_eq!(content.chars().count(), 51); // 50 + ellipsis
636        assert_eq!(entries[0]["truncated"], true);
637    }
638    #[tokio::test]
639    async fn missing_transcripts_dir_returns_ok_false() {
640        let broker = AnyBroker::local();
641        let _ = broker.subscribe("_").await;
642        let cfg = Arc::new(AgentConfig {
643            id: "kate".into(),
644            model: ModelConfig {
645                provider: "stub".into(),
646                model: "m1".into(),
647            },
648            plugins: vec![],
649            heartbeat: HeartbeatConfig::default(),
650            config: AgentRuntimeConfig::default(),
651            system_prompt: String::new(),
652            workspace: String::new(),
653            skills: vec![],
654            skills_dir: "./skills".into(),
655            skill_overrides: Default::default(),
656            transcripts_dir: String::new(), // empty!
657            dreaming: Default::default(),
658            workspace_git: Default::default(),
659            tool_rate_limits: None,
660            tool_args_validation: None,
661            extra_docs: Vec::new(),
662            inbound_bindings: Vec::new(),
663            allowed_tools: Vec::new(),
664            sender_rate_limit: None,
665            allowed_delegates: Vec::new(),
666            accept_delegates_from: Vec::new(),
667            description: String::new(),
668            outbound_allowlist: Default::default(),
669            google_auth: None,
670            credentials: Default::default(),
671            link_understanding: serde_json::Value::Null,
672            web_search: serde_json::Value::Null,
673            pairing_policy: serde_json::Value::Null,
674            language: None,
675            context_optimization: None,
676            dispatch_policy: Default::default(),
677            plan_mode: Default::default(),
678            remote_triggers: Vec::new(),
679            lsp: nexo_config::types::lsp::LspPolicy::default(),
680            config_tool: nexo_config::types::config_tool::ConfigToolPolicy::default(),
681            team: nexo_config::types::team::TeamPolicy::default(),
682            proactive: Default::default(),
683            repl: Default::default(),
684            auto_dream: None,
685            assistant_mode: None,
686            away_summary: None,
687            brief: None,
688            channels: None,
689            auto_approve: false,
690            extract_memories: None,
691            event_subscribers: Vec::new(),
692            tenant_id: None,
693            extensions_config: std::collections::BTreeMap::new(),
694            active: true,
695        });
696        let sessions = Arc::new(SessionManager::new(Duration::from_secs(60), 20));
697        let ctx = AgentContext::new("kate", cfg, broker, sessions);
698        let tool = SessionLogsTool::new();
699        let out = tool
700            .call(&ctx, json!({ "action": "list_sessions" }))
701            .await
702            .unwrap();
703        assert_eq!(out["ok"], false);
704        assert!(out["error"].as_str().unwrap().contains("transcripts_dir"));
705    }
706    #[tokio::test]
707    async fn unknown_action_errors() {
708        let (tool, ctx, _, _) = setup().await;
709        let err = tool
710            .call(&ctx, json!({ "action": "frobnicate" }))
711            .await
712            .err()
713            .unwrap();
714        assert!(err.to_string().contains("unknown action"));
715    }
716
717    #[tokio::test]
718    async fn search_uses_fts_when_index_present() {
719        let (_tool_unused, ctx, dir, sid) = setup().await;
720        // Build index, write entries through the indexed writer.
721        let idx_path = dir.join("idx.db");
722        let index = std::sync::Arc::new(
723            crate::agent::transcripts_index::TranscriptsIndex::open(&idx_path)
724                .await
725                .unwrap(),
726        );
727        let writer = TranscriptWriter::with_extras(
728            &dir,
729            "kate",
730            std::sync::Arc::new(crate::agent::redaction::Redactor::disabled()),
731            Some(index.clone()),
732        );
733        for (role, content) in [
734            (TranscriptRole::User, "buscame el codigo secreto"),
735            (TranscriptRole::Assistant, "no comparto eso"),
736        ] {
737            writer
738                .append_entry(
739                    sid,
740                    TranscriptEntry {
741                        timestamp: chrono::Utc::now(),
742                        role,
743                        content: content.into(),
744                        message_id: None,
745                        source_plugin: "wa".into(),
746                        sender_id: None,
747                    },
748                )
749                .await
750                .unwrap();
751        }
752        let tool = SessionLogsTool::new().with_index(index);
753        let out = tool
754            .call(&ctx, json!({"action": "search", "query": "codigo"}))
755            .await
756            .unwrap();
757        assert_eq!(out["ok"], true);
758        assert_eq!(out["backend"], "fts5");
759        assert_eq!(out["count"], 1);
760    }
761}