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;
14pub 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 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_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 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); 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(), 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 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}