diff --git a/crates/lore-core/src/search.rs b/crates/lore-core/src/search.rs index 363aec77..1b5ebf3f 100644 --- a/crates/lore-core/src/search.rs +++ b/crates/lore-core/src/search.rs @@ -370,6 +370,53 @@ pub fn search_page( Ok(SearchPage { hits, next_cursor }) } +/// One row of a session's redacted projection, as [`session_documents_page`] +/// returns it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RedactedDocument { + /// The projection row id; pass the last one back as `after_id`. + pub id: i64, + /// The message role for a message part, otherwise the projected field + /// (`title`, `patch`). + pub label: String, + pub text: String, +} + +/// Read a session's searchable text back from the redacted `search_document` +/// projection, in ingest order (title, then messages by seq, then patches). +/// +/// This is the read for surfaces that hand archive text to a model: it can only +/// return what search could, so a flagged span, a thinking block, or tool +/// payload JSON never leaves the archive through it. `query::get_session` is +/// the cleartext read and stays local to the UI. +pub fn session_documents_page( + conn: &Connection, + session_id: &str, + after_id: Option, + limit: i64, +) -> Result> { + let mut stmt = conn.prepare( + "SELECT sd.id, COALESCE(m.role, sd.field), sd.redacted_text + FROM search_document sd + LEFT JOIN message_part p ON sd.source_kind = 'message_part' AND p.id = sd.source_id + LEFT JOIN message m ON m.id = p.message_id + WHERE sd.session_id = ?1 AND sd.id > ?2 + ORDER BY sd.id + LIMIT ?3", + )?; + let rows = stmt.query_map( + rusqlite::params![session_id, after_id.unwrap_or(0), limit.clamp(1, MAX_LIMIT)], + |row| { + Ok(RedactedDocument { + id: row.get(0)?, + label: row.get(1)?, + text: row.get(2)?, + }) + }, + )?; + Ok(rows.collect::>()?) +} + /// Build the outer keyset predicate (with a leading ` WHERE `) and its bound /// params for `sort` and `cursor`. Empty when there is no cursor. Each arm keeps /// only rows strictly after the cursor in that sort's total order; `started_at` diff --git a/crates/lore-core/tests/search.rs b/crates/lore-core/tests/search.rs index 6f25394c..37ef08e6 100644 --- a/crates/lore-core/tests/search.rs +++ b/crates/lore-core/tests/search.rs @@ -6,7 +6,7 @@ use lore_core::adapters::claude_code::ClaudeCodeAdapter; use lore_core::adapters::codex::CodexAdapter; use lore_core::ingest::persist_session; -use lore_core::search::{search, search_page, SortOrder, HIGHLIGHT_START}; +use lore_core::search::{search, search_page, session_documents_page, SortOrder, HIGHLIGHT_START}; use lore_core::storage::blob::BlobStore; use rusqlite::Connection; @@ -241,6 +241,35 @@ fn snippets_never_surface_a_secret() { ); } +#[test] +fn session_documents_read_back_redacted_text_in_order() { + let conn = lore_core::storage::open_in_memory().unwrap(); + let (_bd, blobs) = store(); + let secret = format!("ghp{}", "_0123456789abcdefghijklmnopqrstuvwxyz"); + let sid = persist_claude( + &conn, + &blobs, + &user_message("r1", "/p", &format!("deploy with token {secret} now")), + "r1", + ); + + let docs = session_documents_page(&conn, &sid, None, 50).unwrap(); + assert_eq!(docs.len(), 1); + assert_eq!(docs[0].label, "user"); + assert!(docs[0].text.starts_with("deploy with token ")); + assert!(docs[0].text.ends_with(" now")); + assert!(!docs[0] + .text + .contains("0123456789abcdefghijklmnopqrstuvwxyz")); + + assert!(session_documents_page(&conn, &sid, Some(docs[0].id), 50) + .unwrap() + .is_empty()); + assert!(session_documents_page(&conn, "no-such-session", None, 50) + .unwrap() + .is_empty()); +} + #[test] fn keyset_pagination_is_stable_and_complete() { // SEARCH.md §6: paging must reproduce the single-shot ranked order exactly, diff --git a/crates/lorectl/src/cli.rs b/crates/lorectl/src/cli.rs index a0471355..ddd396ee 100644 --- a/crates/lorectl/src/cli.rs +++ b/crates/lorectl/src/cli.rs @@ -33,6 +33,8 @@ pub enum Command { Hook, /// Longer-form summary of archived work. Report, + /// MCP server over stdio, so agents can search the archive. + Mcp, } /// Every command, in the order `--help` lists them. @@ -42,6 +44,7 @@ pub const COMMANDS: &[Command] = &[ Command::Inspect, Command::Search, Command::Hook, + Command::Mcp, Command::Report, ]; @@ -56,6 +59,7 @@ impl Command { Command::Search => "search", Command::Hook => "hook", Command::Report => "report", + Command::Mcp => "mcp", } } @@ -69,6 +73,7 @@ impl Command { Command::Search => "Full-text search across archived sessions", Command::Hook => "Agent hook entry point; run by an agent, not a person", Command::Report => "Longer-form summary of archived work", + Command::Mcp => "MCP server on stdin/stdout; run by an agent, not a person", } } @@ -81,7 +86,8 @@ impl Command { | Command::Search | Command::Inspect | Command::Status - | Command::Hook => true, + | Command::Hook + | Command::Mcp => true, Command::Report => false, } } @@ -97,7 +103,7 @@ impl Command { Command::Search => Some("QUERY"), Command::Inspect => Some("SESSION_ID"), Command::Hook => Some("EVENT"), - Command::Scan | Command::Status | Command::Report => None, + Command::Scan | Command::Status | Command::Report | Command::Mcp => None, } } @@ -284,7 +290,10 @@ mod tests { .filter(|c| c.is_implemented()) .map(|c| c.name()) .collect(); - assert_eq!(implemented, ["scan", "status", "inspect", "search", "hook"]); + assert_eq!( + implemented, + ["scan", "status", "inspect", "search", "hook", "mcp"] + ); } #[test] diff --git a/crates/lorectl/src/main.rs b/crates/lorectl/src/main.rs index ff9a0ca9..1d912ada 100644 --- a/crates/lorectl/src/main.rs +++ b/crates/lorectl/src/main.rs @@ -17,6 +17,7 @@ mod cli; mod exit; mod help; mod hook; +mod mcp; mod query; mod scan; mod status; @@ -58,6 +59,7 @@ fn run() -> Result { Action::Run(cli::Command::Scan) => scan::run(&invocation), Action::Run(cli::Command::Status) => status::run(&invocation), Action::Run(cli::Command::Hook) => hook::run(&invocation), + Action::Run(cli::Command::Mcp) => mcp::run(&invocation), Action::Run(cli::Command::Search) => query::search(&invocation), Action::Run(cli::Command::Inspect) => query::inspect(&invocation), Action::Run(command) => { diff --git a/crates/lorectl/src/mcp.rs b/crates/lorectl/src/mcp.rs new file mode 100644 index 00000000..2ec51ffa --- /dev/null +++ b/crates/lorectl/src/mcp.rs @@ -0,0 +1,296 @@ +//! `lorectl mcp` — the archive as a Model Context Protocol server. +//! +//! An agent launches this as a child process and speaks JSON-RPC 2.0 to it, +//! one message per line on stdin and stdout (the MCP stdio transport). Pipes +//! only: the no-network guarantee covers this command like every other. +//! +//! ## Only redacted text leaves +//! +//! Whatever a tool returns is sent on to the agent's model provider, so every +//! tool reads from the redacted search projection and nothing else. That is +//! why `read_session` is built on [`search::session_documents_page`] rather +//! than `query::get_session`, which returns cleartext for the local UI, and why +//! no tool returns a session title: `agent_session.title` is cleartext too (a +//! fallback title echoes the first user message), while the redacted copy of a +//! native title is already the first entry `read_session` returns. +//! +//! ## Failure stays inside the protocol +//! +//! Stdout belongs to the protocol, so nothing else is ever printed to it. A +//! tool that fails (no archive yet, an unknown session) answers with an +//! `isError` result the agent can read, rather than ending the server: an +//! archive that appears later is picked up on the next call, because each call +//! opens the database afresh. + +use std::io::{BufRead, Write}; +use std::path::Path; + +use lore_core::paths::ARCHIVE_DB_FILENAME; +use lore_core::search::{self, SortOrder, HIGHLIGHT_END, HIGHLIGHT_START}; +use lore_core::{query, storage}; +use rusqlite::Connection; +use serde_json::{json, Value}; + +use crate::cli::Invocation; +use crate::exit::{self, CliError}; + +/// The protocol revisions this server can speak. The tools surface it uses is +/// the same in all of them, so a client's requested revision is echoed back +/// when it is one of these, and the newest is offered otherwise. +const PROTOCOL_VERSIONS: &[&str] = &["2025-06-18", "2025-03-26", "2024-11-05"]; + +const SEARCH_LIMIT: i64 = 20; +const READ_PAGE_DOCUMENTS: i64 = 50; +/// Characters of session text per `read_session` page, so one call cannot +/// flood the agent's context window. +const READ_PAGE_CHARS: usize = 24_000; + +const INSTRUCTIONS: &str = "Lore archives past Claude Code and Codex sessions on this machine. \ +Use search_sessions to find earlier work, decisions, or errors, then read_session to read one. \ +Text that matched a secret detector is masked."; + +/// JSON-RPC error codes this server answers with. +const PARSE_ERROR: i64 = -32700; +const INVALID_REQUEST: i64 = -32600; +const METHOD_NOT_FOUND: i64 = -32601; +const INVALID_PARAMS: i64 = -32602; + +struct Tool { + name: &'static str, + description: &'static str, + input_schema: fn() -> Value, + call: fn(&Path, &Value) -> Result, +} + +const TOOLS: &[Tool] = &[ + Tool { + name: "search_sessions", + description: "Full-text search across archived coding-agent sessions. Plain words are \ +ANDed. Filters: agent:claude-code|codex, path:, tool:, model:, repo:, \ +branch:, commit:, before:, after:, has:error, has:patch. Returns \ +session ids with redacted snippets.", + input_schema: search_schema, + call: search_sessions, + }, + Tool { + name: "read_session", + description: "Read one archived session's text in order: its title, each message \ +labelled by role, then any recorded patches. Paged; pass next_cursor back to continue. \ +Thinking and raw tool payloads are not included.", + input_schema: read_schema, + call: read_session, + }, +]; + +fn search_schema() -> Value { + json!({ + "type": "object", + "properties": { + "query": { "type": "string", "description": "Words and filters to search for." }, + "sort": { "type": "string", "enum": ["relevance", "newest", "oldest"] }, + "cursor": { "type": "string", "description": "next_cursor from a previous call." } + }, + "required": ["query"] + }) +} + +fn read_schema() -> Value { + json!({ + "type": "object", + "properties": { + "session_id": { "type": "string" }, + "cursor": { "type": "string", "description": "next_cursor from a previous call." } + }, + "required": ["session_id"] + }) +} + +/// Serve MCP on stdin/stdout until stdin closes. +pub fn run(invocation: &Invocation) -> Result { + let archive_dir = invocation.archive_dir()?; + let stdin = std::io::stdin().lock(); + let mut stdout = std::io::stdout().lock(); + for line in stdin.lines() { + let Ok(line) = line else { break }; + if line.trim().is_empty() { + continue; + } + if let Some(response) = handle(&archive_dir, &line) { + if writeln!(stdout, "{response}") + .and_then(|()| stdout.flush()) + .is_err() + { + break; + } + } + } + Ok(exit::OK) +} + +/// Answer one line of input. `None` for a notification, which gets no reply. +fn handle(archive_dir: &Path, line: &str) -> Option { + let Ok(message) = serde_json::from_str::(line) else { + return Some(error(&Value::Null, PARSE_ERROR, "not valid JSON")); + }; + let id = message.get("id").cloned(); + let Some(method) = message.get("method").and_then(Value::as_str) else { + return Some(error( + &id.unwrap_or(Value::Null), + INVALID_REQUEST, + "not a JSON-RPC request", + )); + }; + let id = id?; + let params = message.get("params").cloned().unwrap_or(Value::Null); + + let result = match method { + "initialize" => Ok(initialize(¶ms)), + "ping" => Ok(json!({})), + "tools/list" => Ok(json!({ "tools": TOOLS.iter().map(describe).collect::>() })), + "tools/call" => call_tool(archive_dir, ¶ms), + _ => Err((METHOD_NOT_FOUND, format!("unknown method `{method}`"))), + }; + Some(match result { + Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }), + Err((code, message)) => error(&id, code, &message), + }) +} + +fn initialize(params: &Value) -> Value { + let requested = params.get("protocolVersion").and_then(Value::as_str); + let version = requested + .filter(|v| PROTOCOL_VERSIONS.contains(v)) + .unwrap_or(PROTOCOL_VERSIONS[0]); + json!({ + "protocolVersion": version, + "capabilities": { "tools": {} }, + "serverInfo": { "name": "lorectl", "version": lore_core::version() }, + "instructions": INSTRUCTIONS, + }) +} + +fn describe(tool: &Tool) -> Value { + json!({ + "name": tool.name, + "description": tool.description, + "inputSchema": (tool.input_schema)(), + }) +} + +fn call_tool(archive_dir: &Path, params: &Value) -> Result { + let name = params + .get("name") + .and_then(Value::as_str) + .unwrap_or_default(); + let tool = TOOLS + .iter() + .find(|t| t.name == name) + .ok_or_else(|| (INVALID_PARAMS, format!("unknown tool `{name}`")))?; + let arguments = params.get("arguments").cloned().unwrap_or(json!({})); + Ok(match (tool.call)(archive_dir, &arguments) { + Ok(value) => json!({ + "content": [{ "type": "text", "text": value.to_string() }], + "structuredContent": value, + }), + Err(message) => json!({ + "content": [{ "type": "text", "text": message }], + "isError": true, + }), + }) +} + +fn error(id: &Value, code: i64, message: &str) -> Value { + json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } }) +} + +/// Open the archive read-only, turning failure into text an agent can act on. +fn open(archive_dir: &Path) -> Result { + storage::open_read_only(&archive_dir.join(ARCHIVE_DB_FILENAME)).map_err(|e| { + let error = CliError::from(e); + match error.advice() { + Some(advice) => format!("{error}; {advice}"), + None => error.to_string(), + } + }) +} + +fn string_arg<'a>(arguments: &'a Value, name: &str) -> Option<&'a str> { + arguments.get(name).and_then(Value::as_str) +} + +fn search_sessions(archive_dir: &Path, arguments: &Value) -> Result { + let query_text = string_arg(arguments, "query").ok_or("`query` is required")?; + let sort = SortOrder::parse(string_arg(arguments, "sort")); + let conn = open(archive_dir)?; + let page = search::search_page( + &conn, + query_text, + SEARCH_LIMIT, + string_arg(arguments, "cursor"), + sort, + ) + .map_err(|e| e.to_string())?; + let hits: Vec = page + .hits + .iter() + .map(|hit| { + json!({ + "session_id": hit.session_id, + "agent_id": hit.agent_id, + "started_at_ms": hit.started_at, + "source_kind": hit.source_kind, + "snippet": hit.snippet.replace(HIGHLIGHT_START, "").replace(HIGHLIGHT_END, ""), + }) + }) + .collect(); + Ok(json!({ "hits": hits, "next_cursor": page.next_cursor })) +} + +fn read_session(archive_dir: &Path, arguments: &Value) -> Result { + let session_id = string_arg(arguments, "session_id").ok_or("`session_id` is required")?; + let after_id = match string_arg(arguments, "cursor") { + Some(cursor) => Some(cursor.parse::().map_err(|_| "`cursor` is not valid")?), + None => None, + }; + let conn = open(archive_dir)?; + let summary = query::get_session(&conn, session_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("no session `{session_id}` in the archive"))? + .summary; + let documents = + search::session_documents_page(&conn, session_id, after_id, READ_PAGE_DOCUMENTS) + .map_err(|e| e.to_string())?; + + let full_page = documents.len() == usize::try_from(READ_PAGE_DOCUMENTS).unwrap_or(usize::MAX); + let mut budget = READ_PAGE_CHARS; + let mut entries = Vec::new(); + let mut last_id = None; + for document in &documents { + if budget == 0 { + break; + } + let length = document.text.chars().count(); + let (text, truncated) = if length > budget { + (document.text.chars().take(budget).collect(), true) + } else { + (document.text.clone(), false) + }; + budget = budget.saturating_sub(length); + last_id = Some(document.id); + let mut entry = json!({ "label": document.label, "text": text }); + if truncated { + entry["truncated"] = json!(true); + } + entries.push(entry); + } + let more = entries.len() < documents.len() || full_page; + let next_cursor = last_id.filter(|_| more).map(|id| id.to_string()); + + Ok(json!({ + "session_id": summary.id, + "agent_id": summary.agent_id, + "started_at_ms": summary.started_at, + "entries": entries, + "next_cursor": next_cursor, + })) +} diff --git a/crates/lorectl/tests/mcp.rs b/crates/lorectl/tests/mcp.rs new file mode 100644 index 00000000..dff6c997 --- /dev/null +++ b/crates/lorectl/tests/mcp.rs @@ -0,0 +1,290 @@ +//! `lorectl mcp` end to end: a real process spoken to over stdin/stdout, the +//! way an agent launches it. +//! +//! Agent homes are isolated per child process; no real `~/.claude` or `~/.codex` +//! history is read (`docs/development/TESTING.md`). +#![allow(clippy::expect_used, clippy::unwrap_used)] + +mod common; + +use std::io::Write; +use std::path::Path; +use std::process::{Command, Output, Stdio}; + +use serde_json::{json, Value}; + +const SECRET_BODY: &str = "0123456789abcdefghijklmnopqrstuvwxyz"; +const SESSION_UUID: &str = "11111111-2222-3333-4444-555555555555"; + +fn lorectl(archive: &Path, homes: &Path, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_lorectl")) + .args(args) + .arg("--archive") + .arg(archive) + .env("CLAUDE_CONFIG_DIR", homes.join("claude")) + .env("CODEX_HOME", homes.join("codex")) + .env_remove("LORE_ARCHIVE_DIR") + .output() + .expect("spawn lorectl") +} + +/// Send `messages` to `lorectl mcp`, close stdin, and return every reply. +fn converse(archive: &Path, messages: &[Value]) -> Vec { + let mut child = Command::new(env!("CARGO_BIN_EXE_lorectl")) + .arg("mcp") + .arg("--archive") + .arg(archive) + .env_remove("LORE_ARCHIVE_DIR") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn lorectl mcp"); + { + let mut stdin = child.stdin.take().unwrap(); + for message in messages { + writeln!(stdin, "{message}").unwrap(); + } + } + let out = child.wait_with_output().unwrap(); + assert_eq!( + out.status.code(), + Some(0), + "server exits 0 when stdin closes" + ); + String::from_utf8(out.stdout) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).expect("every stdout line is JSON")) + .collect() +} + +fn request(id: i64, method: &str, params: Value) -> Value { + json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }) +} + +fn call(id: i64, tool: &str, arguments: Value) -> Value { + request( + id, + "tools/call", + json!({ "name": tool, "arguments": arguments }), + ) +} + +/// One Claude Code session: a user turn carrying a secret, then `replies` +/// numbered assistant turns. +fn plant_session(homes: &Path, replies: usize) { + let project = homes.join("claude/projects/p"); + std::fs::create_dir_all(&project).unwrap(); + let mut jsonl = format!( + "{{\"type\":\"user\",\"uuid\":\"u0\",\"sessionId\":\"{SESSION_UUID}\",\"cwd\":\"/p\",\ +\"message\":{{\"role\":\"user\",\"content\":\"deploy the zanzibar service with token ghp_{SECRET_BODY}\"}}}}\n" + ); + let mut parent = "u0".to_string(); + for i in 0..replies { + let uuid = format!("a{i}"); + jsonl.push_str(&format!( + "{{\"type\":\"assistant\",\"uuid\":\"{uuid}\",\"parentUuid\":\"{parent}\",\ +\"sessionId\":\"{SESSION_UUID}\",\"cwd\":\"/p\",\"message\":{{\"role\":\"assistant\",\ +\"content\":[{{\"type\":\"text\",\"text\":\"step {i}\"}}]}}}}\n" + )); + parent = uuid; + } + std::fs::write(project.join(format!("{SESSION_UUID}.jsonl")), jsonl).unwrap(); +} + +fn scanned_archive(replies: usize) -> (tempfile::TempDir, tempfile::TempDir) { + let archive = tempfile::tempdir().unwrap(); + let homes = common::empty_homes(); + plant_session(homes.path(), replies); + let out = lorectl(archive.path(), homes.path(), &["scan"]); + assert_eq!(out.status.code(), Some(0), "seeding scan failed"); + (archive, homes) +} + +fn only_session_id(archive: &Path) -> String { + let conn = lore_core::storage::open_read_only(&common::db_path(archive)).unwrap(); + conn.query_row("SELECT id FROM agent_session", [], |r| r.get(0)) + .unwrap() +} + +fn structured(reply: &Value) -> &Value { + assert_eq!(reply["result"].get("isError"), None, "{reply}"); + &reply["result"]["structuredContent"] +} + +#[test] +fn speaks_the_mcp_handshake() { + let archive = tempfile::tempdir().unwrap(); + let replies = converse( + archive.path(), + &[ + request(1, "initialize", json!({ "protocolVersion": "2025-03-26" })), + json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }), + request(2, "tools/list", json!({})), + request(3, "ping", json!({})), + request(4, "resources/list", json!({})), + json!("not a request"), + ], + ); + + assert_eq!( + replies.len(), + 5, + "the notification gets no reply: {replies:?}" + ); + assert_eq!(replies[0]["id"], 1); + assert_eq!(replies[0]["result"]["protocolVersion"], "2025-03-26"); + assert_eq!(replies[0]["result"]["serverInfo"]["name"], "lorectl"); + let names: Vec<&str> = replies[1]["result"]["tools"] + .as_array() + .unwrap() + .iter() + .map(|t| t["name"].as_str().unwrap()) + .collect(); + assert_eq!(names, ["search_sessions", "read_session"]); + assert_eq!( + replies[2], + json!({ "jsonrpc": "2.0", "id": 3, "result": {} }) + ); + assert_eq!(replies[3]["error"]["code"], -32601); + assert_eq!(replies[4]["error"]["code"], -32600); +} + +#[test] +fn an_unknown_protocol_version_is_answered_with_the_newest() { + let archive = tempfile::tempdir().unwrap(); + let replies = converse( + archive.path(), + &[request( + 1, + "initialize", + json!({ "protocolVersion": "1999-01-01" }), + )], + ); + assert_eq!(replies[0]["result"]["protocolVersion"], "2025-06-18"); +} + +#[test] +fn a_missing_archive_is_a_tool_error_not_a_dead_server() { + let parent = tempfile::tempdir().unwrap(); + let missing = parent.path().join("not-yet"); + let replies = converse( + &missing, + &[ + call(1, "search_sessions", json!({ "query": "anything" })), + request(2, "ping", json!({})), + ], + ); + assert_eq!(replies[0]["result"]["isError"], true); + let text = replies[0]["result"]["content"][0]["text"].as_str().unwrap(); + assert!(text.contains("lorectl scan"), "names the fix: {text}"); + assert_eq!( + replies[1]["result"], + json!({}), + "still serving after the error" + ); + assert!(!missing.exists(), "the server created an archive"); +} + +#[test] +fn search_then_read_finds_the_session_without_its_secret() { + let (archive, _homes) = scanned_archive(2); + let replies = converse( + archive.path(), + &[ + call(1, "search_sessions", json!({ "query": "zanzibar" })), + call(2, "search_sessions", json!({ "query": "nonexistentterm" })), + ], + ); + let hits = structured(&replies[0])["hits"].as_array().unwrap(); + assert_eq!(hits.len(), 1); + let session_id = hits[0]["session_id"].as_str().unwrap().to_owned(); + let snippet = hits[0]["snippet"].as_str().unwrap(); + assert!(snippet.contains("zanzibar"), "{snippet}"); + assert_eq!(structured(&replies[1])["hits"], json!([])); + + let replies = [ + replies, + converse( + archive.path(), + &[call(1, "read_session", json!({ "session_id": session_id }))], + ), + ] + .concat(); + let read = structured(&replies[2]); + assert_eq!(read["session_id"], session_id); + let entries: Vec<(&str, &str)> = read["entries"] + .as_array() + .unwrap() + .iter() + .map(|e| (e["label"].as_str().unwrap(), e["text"].as_str().unwrap())) + .collect(); + assert_eq!(entries.len(), 3); + assert_eq!(entries[1], ("assistant", "step 0")); + assert_eq!(entries[2], ("assistant", "step 1")); + assert_eq!(entries[0].0, "user"); + assert!(entries[0] + .1 + .starts_with("deploy the zanzibar service with token ")); + assert_eq!(read["next_cursor"], Value::Null); + + for reply in &replies { + assert!( + !reply.to_string().contains(SECRET_BODY), + "a secret left the archive: {reply}" + ); + } +} + +#[test] +fn read_session_pages_until_the_cursor_runs_out() { + let (archive, _homes) = scanned_archive(60); + let session_id = only_session_id(archive.path()); + let first = converse( + archive.path(), + &[call(1, "read_session", json!({ "session_id": session_id }))], + ); + let page = structured(&first[0]); + assert_eq!(page["entries"].as_array().unwrap().len(), 50); + let cursor = page["next_cursor"] + .as_str() + .expect("a full page has a cursor"); + + let second = converse( + archive.path(), + &[call( + 1, + "read_session", + json!({ "session_id": session_id, "cursor": cursor }), + )], + ); + let page = structured(&second[0]); + let entries = page["entries"].as_array().unwrap(); + assert_eq!(entries.len(), 11, "60 replies plus the user turn, minus 50"); + assert_eq!(entries[10]["text"], "step 59"); + assert_eq!(page["next_cursor"], Value::Null); +} + +#[test] +fn unknown_sessions_and_tools_are_reported() { + let (archive, _homes) = scanned_archive(1); + let replies = converse( + archive.path(), + &[ + call(1, "read_session", json!({ "session_id": "nope" })), + call(2, "search_sessions", json!({})), + call(3, "delete_everything", json!({})), + ], + ); + assert_eq!(replies[0]["result"]["isError"], true); + assert_eq!( + replies[0]["result"]["content"][0]["text"], + "no session `nope` in the archive" + ); + assert_eq!( + replies[1]["result"]["content"][0]["text"], + "`query` is required" + ); + assert_eq!(replies[2]["error"]["code"], -32602); +}