From b90939bfc9f2aec2e279851b44eda0a98a2c466e Mon Sep 17 00:00:00 2001 From: Muhammad Ali Hashar Date: Sun, 11 Oct 2026 11:16:23 -0400 Subject: [PATCH] feat(search): add --tool-output to query and tool_output to MCP search_sessions --- cli/src/lib.rs | 131 ++++++++-- cli/src/mcp.rs | 13 +- cli/tests/query_tool_output.rs | 189 +++++++++++++++ docs/usage.md | 7 +- tests/integration/search.rs | 426 +++++++++++++++++++++++++++++++++ 5 files changed, 742 insertions(+), 24 deletions(-) create mode 100644 cli/tests/query_tool_output.rs diff --git a/cli/src/lib.rs b/cli/src/lib.rs index fadca96..24da058 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -253,13 +253,18 @@ pub enum SessionCommand { /// Matching is literal and case-insensitive: the pattern must appear in a /// line exactly as typed, spaces included. A pattern prints ranked hits, /// labeled by what matched (user text, assistant text, thinking, tool use, - /// session metadata). The picker filters per keystroke; Enter continues the - /// selection, Esc cancels. + /// session metadata). Tool output is searched only with `--tool-output`. + /// The picker filters per keystroke; Enter continues the selection, Esc + /// cancels. Query { /// Text to find, matched literally; omit to pick interactively // Other: without a hint, generated completions fall back to filenames. #[arg(value_hint = clap::ValueHint::Other)] pattern: Option, + /// Also search tool output: command output, file contents read, and + /// other tool responses + #[arg(long)] + tool_output: bool, /// Continue the picked session in this harness #[arg(long, value_name = "HARNESS", value_parser = HarnessParser)] with: Option, @@ -413,6 +418,7 @@ pub fn run_session(command: SessionCommand, options: &Options) -> Result Result query::cmd_query( pattern, + tool_output, with, from, cwd.as_deref(), @@ -2397,15 +2404,22 @@ mod query { /// is one literal needle, spaces included, matched case-insensitively. /// Fuzzy scoring matches any line whose characters contain the pattern in /// order, which turns up lines that only look like matches. - pub(super) fn user_query(pattern: &str) -> Query { + /// + /// Tool output is indexed either way but searched only when `tool_output` + /// is set: [`Origin::DEFAULT`] leaves it out, [`Origin::ALL`] puts it in. + pub(super) fn user_query(pattern: &str, tool_output: bool) -> Query { let mut q = Query::substring(pattern); q.case = Case::Insensitive; + if tool_output { + q.origins = Origin::ALL.to_vec(); + } q } #[allow(clippy::too_many_arguments)] pub(super) fn cmd_query( pattern: Option, + tool_output: bool, with: Option, from: Option, cwd: Option<&Path>, @@ -2421,10 +2435,10 @@ mod query { if with.is_some() { eprintln!("warning: --with ignored with a pattern"); } - one_shot(&index, &pattern); + one_shot(&index, &pattern, tool_output); Ok(std::process::ExitCode::SUCCESS) } - None => match tui::pick(&index)? { + None => match tui::pick(&index, tool_output)? { // Cancelled; terminal already restored, nothing to continue. None => Ok(std::process::ExitCode::SUCCESS), Some(key) => { @@ -2628,9 +2642,9 @@ mod query { } /// Print ranked hits for a pattern, colorized when stdout is a terminal. - fn one_shot(index: &Index, pattern: &str) { + fn one_shot(index: &Index, pattern: &str, tool_output: bool) { use std::io::{IsTerminal, Write}; - let mut q = user_query(pattern); + let mut q = user_query(pattern, tool_output); q.limit = Some(20); q.hits_per_doc = Some(3); let matches = index.query(&q); @@ -2797,7 +2811,9 @@ mod query { /// Interactive picker over the index. Returns the chosen doc, or /// `None` on cancel. The terminal is fully restored either way. - pub(super) fn pick(index: &Index) -> Result, String> { + /// `tool_output` widens every keystroke's search as in + /// [`super::user_query`]. + pub(super) fn pick(index: &Index, tool_output: bool) -> Result, String> { if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() { // Raw mode and the alternate screen need real terminal stdio. Err("interactive query needs a terminal (pass a pattern instead)".into()) @@ -2807,7 +2823,7 @@ mod query { let mut selected = 0usize; let mut stdin = Input::new(std::io::stdin().lock()); let (mut rows, mut cols) = term_size(); - let mut results = query(index, &input, rows); + let mut results = query(index, &input, rows, tool_output); let mut pending = None; render(&input, &results, selected, index.len(), rows, cols); @@ -2828,7 +2844,7 @@ mod query { // results to appear. render_input(&input); (rows, cols) = term_size(); - results = query(index, &input, rows); + results = query(index, &input, rows, tool_output); render(&input, &results, selected, index.len(), rows, cols); } Key::Up | Key::Down => { @@ -2840,7 +2856,7 @@ mod query { let (new_rows, new_cols) = term_size(); if new_rows != rows { - results = query(index, &input, new_rows); + results = query(index, &input, new_rows, tool_output); selected = selected.min(results.len().saturating_sub(1)); } (rows, cols) = (new_rows, new_cols); @@ -2891,10 +2907,10 @@ mod query { } } - fn query<'a>(index: &'a Index, input: &str, rows: usize) -> Results<'a> { + fn query<'a>(index: &'a Index, input: &str, rows: usize, tool_output: bool) -> Results<'a> { let visible = rows.saturating_sub(2).max(1); let searching = !input.trim().is_empty(); - let mut q = super::user_query(input); + let mut q = super::user_query(input, tool_output); q.limit = Some(visible); q.hits_per_doc = searching.then_some(visible); let docs = index.query(&q); @@ -3276,7 +3292,7 @@ mod query { mod tui { use txcript::search::{DocKey, Index}; - pub(super) fn pick(_: &Index) -> Result, String> { + pub(super) fn pick(_: &Index, _: bool) -> Result, String> { Err("the interactive picker is unix-only; pass a pattern instead".into()) } } @@ -3284,15 +3300,90 @@ mod query { #[cfg(test)] mod tests { use super::user_query; - use txcript::search::{Case, Mode}; + use txcript::common::{Block, Message, Meta, Role, ToolOutput}; + use txcript::search::{Case, DocKey, Index, Mode, Origin}; + use txcript::{HarnessId, Transcript}; #[test] fn patterns_are_literal_and_case_insensitive() { - let q = user_query("Cargo build"); - assert_eq!(q.mode, Mode::Substring); - assert_eq!(q.case, Case::Insensitive); - // One needle, spaces included: the space is not an atom separator. - assert_eq!(q.pattern, "Cargo build"); + for tool_output in [false, true] { + let q = user_query("Cargo build", tool_output); + assert_eq!(q.mode, Mode::Substring); + assert_eq!(q.case, Case::Insensitive); + // One needle, spaces included: the space is not an atom separator. + assert_eq!(q.pattern, "Cargo build"); + } + } + + #[test] + fn tool_output_flag_only_widens_the_origins() { + assert_eq!(user_query("x", false).origins, Origin::DEFAULT.to_vec()); + assert_eq!(user_query("x", true).origins, Origin::ALL.to_vec()); + // Everything else about the query is the same either way. + let (mut narrow, wide) = (user_query("x", false), user_query("x", true)); + narrow.origins = wide.origins.clone(); + assert_eq!(narrow, wide); + } + + /// Text a tool printed, and nothing else in the session, is what the + /// flag exists to reach: `query`, its picker, and the MCP search tool + /// all build their query with `user_query`. + #[test] + fn text_only_in_tool_output_is_found_with_the_flag() { + let at = chrono::DateTime::UNIX_EPOCH; + let message = |role, content| Message { + role, + content, + timestamp: at, + model: None, + stop_reason: None, + usage: None, + }; + let meta = Meta { + id: "s1".to_string(), + timestamp: at, + cwd: None, + git_branch: None, + title: None, + cli_version: None, + model: None, + lineage: None, + }; + let transcript = Transcript::new( + meta, + vec![ + message( + Role::User, + vec![Block::Text { + text: "run the build".to_string(), + }], + ), + message( + Role::User, + vec![Block::ToolResult { + tool_use_id: "t1".to_string(), + content: ToolOutput::Text( + "compiling\nerror: Linker_Fault in libfoo.a".to_string(), + ), + is_error: true, + }], + ), + ], + ); + let mut index = Index::new(); + let key = DocKey { + harness: HarnessId::ClaudeCode, + id: "s1".to_string(), + source: None, + }; + index.insert(key, &transcript); + + assert!(index.query(&user_query("linker_fault", false)).is_empty()); + let found = index.query(&user_query("linker_fault", true)); + assert_eq!(found.len(), 1); + assert_eq!(found[0].hits.len(), 1); + assert_eq!(found[0].hits[0].origin, Origin::ToolResult); + assert_eq!(found[0].hits[0].line, "error: Linker_Fault in libfoo.a"); } } } diff --git a/cli/src/mcp.rs b/cli/src/mcp.rs index e343bcd..a757554 100644 --- a/cli/src/mcp.rs +++ b/cli/src/mcp.rs @@ -62,6 +62,9 @@ struct SearchSessionsRequest { /// Only sessions started at or before this time. RFC3339 string, e.g. /// `2025-12-31T23:59:59Z` or bare date `2025-12-31` (end of UTC day). until: Option, + /// Also search tool output: command output, file contents read, and + /// other tool responses. Omit for false. + tool_output: Option, } #[derive(Debug, Deserialize, JsonSchema)] @@ -306,7 +309,7 @@ impl SessionServer { /// Search local session content with the same matching, harness, and /// working-directory behavior as `txcript query `. #[tool( - description = "Search local coding-agent sessions for a literal, case-insensitive pattern: it must appear in a line exactly as written, spaces included. Optional `from`, `cwd`, `git_branch`, `model`, `since`, and `until` filters match the txcript CLI flags; omitted filters search all sessions.", + description = "Search local coding-agent sessions for a literal, case-insensitive pattern: it must appear in a line exactly as written, spaces included. Tool output (command output, file contents read) is searched only when `tool_output` is true. Optional `from`, `cwd`, `git_branch`, `model`, `since`, and `until` filters match the txcript CLI flags; omitted filters search all sessions.", annotations(title = "Search sessions", read_only_hint = true) )] fn search_sessions( @@ -337,7 +340,8 @@ impl SessionServer { self.cache.as_deref(), ) .map_err(|error| ErrorData::internal_error(error, None))?; - let mut query = super::query::user_query(&request.pattern); + let mut query = + super::query::user_query(&request.pattern, request.tool_output.unwrap_or(false)); // Match the CLI's one-shot output bounds. query.limit = Some(20); query.hits_per_doc = Some(3); @@ -546,6 +550,11 @@ mod tests { assert!(search.input_schema["properties"].get("pattern").is_some()); assert!(search.input_schema["properties"].get("from").is_some()); assert!(search.input_schema["properties"].get("cwd").is_some()); + assert!( + search.input_schema["properties"] + .get("tool_output") + .is_some() + ); assert_eq!( search.input_schema["required"], serde_json::json!(["pattern"]) diff --git a/cli/tests/query_tool_output.rs b/cli/tests/query_tool_output.rs new file mode 100644 index 0000000..156e331 --- /dev/null +++ b/cli/tests/query_tool_output.rs @@ -0,0 +1,189 @@ +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] +//! `txcript query --tool-output` and the MCP `search_sessions` tool's +//! `tool_output` argument, end to end through the built binary. +//! +//! Text that only ever appeared in what a tool printed (a build error, a +//! file's contents) used to be unreachable from both: they searched +//! `Origin::DEFAULT`, which leaves tool output out, with no way to ask for +//! it, while `txcript view` showed the text plainly. +//! +//! Every run points `HOME` and the Claude Code root at a temp dir and passes +//! `--from claude-code`, so no real session store is ever read. + +use std::io::{BufRead, BufReader, Write}; +use std::path::Path; +use std::process::{Command, Stdio}; + +use serde_json::{Value, json}; + +const SESSION: &str = "11111111-2222-3333-4444-555555555555"; +const NEEDLE: &str = "ZEBRAQUUX_LINKER_FAULT"; + +/// One Claude Code session whose only mention of [`NEEDLE`] is in a Bash +/// tool's output. +fn write_session(root: &Path) { + let dir = root.join("claude").join("projects").join("-work-demo"); + std::fs::create_dir_all(&dir).unwrap(); + let line = |v: Value| v.to_string(); + let lines = [ + line(json!({ + "type": "user", "uuid": "u1", "parentUuid": null, "sessionId": SESSION, + "cwd": "/work/demo", "timestamp": "2026-01-02T03:04:05.000Z", + "message": {"role": "user", "content": "run the build please"}, + })), + line(json!({ + "type": "assistant", "uuid": "a1", "parentUuid": "u1", "sessionId": SESSION, + "timestamp": "2026-01-02T03:04:06.000Z", + "message": {"role": "assistant", "model": "claude-opus-4-8", "content": [ + {"type": "text", "text": "Running it."}, + {"type": "tool_use", "id": "t1", "name": "Bash", "input": {"command": "make all"}}, + ]}, + })), + line(json!({ + "type": "user", "uuid": "u2", "parentUuid": "a1", "sessionId": SESSION, + "timestamp": "2026-01-02T03:04:07.000Z", + "message": {"role": "user", "content": [{ + "type": "tool_result", "tool_use_id": "t1", + "content": format!("compiling...\nerror: {NEEDLE} in libfoo.a\nmake: *** Error 1"), + }]}, + })), + line(json!({ + "type": "assistant", "uuid": "a2", "parentUuid": "u2", "sessionId": SESSION, + "timestamp": "2026-01-02T03:04:08.000Z", + "message": {"role": "assistant", "model": "claude-opus-4-8", "content": [ + {"type": "text", "text": "The build failed at link time."}, + ]}, + })), + ]; + std::fs::write( + dir.join(format!("{SESSION}.jsonl")), + lines.join("\n") + "\n", + ) + .unwrap(); +} + +/// The binary, sandboxed to `root`. +fn txcript(root: &Path) -> Command { + let home = root.join("home"); + std::fs::create_dir_all(&home).unwrap(); + let mut cmd = Command::new(env!("CARGO_BIN_EXE_txcript")); + cmd.env("HOME", &home) + .env("USERPROFILE", &home) + .env("APPDATA", &home) + .env("XDG_DATA_HOME", &home) + .env("CODEX_HOME", home.join("codex")) + .env("CLAUDE_CONFIG_DIR", root.join("claude")) + .env_remove("TXCRIPT_CACHE"); + cmd +} + +fn query(root: &Path, args: &[&str]) -> String { + let out = txcript(root) + .args(["query", NEEDLE, "--from", "claude-code"]) + .args(args) + .output() + .unwrap(); + assert!( + out.status.success(), + "query failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8(out.stdout).unwrap() +} + +#[test] +fn query_searches_tool_output_only_when_asked() { + let root = tempfile::tempdir().unwrap(); + write_session(root.path()); + + // The default scope is unchanged: tool output is not searched. + assert_eq!( + query(root.path(), &[]), + format!("no matches for `{NEEDLE}`\n") + ); + + let found = query(root.path(), &["--tool-output"]); + assert!(found.contains(SESSION), "{found}"); + assert!( + found.contains(&format!("[tool_result] error: {NEEDLE} in libfoo.a")), + "{found}" + ); +} + +#[test] +fn query_tool_output_reads_through_the_search_cache() { + let root = tempfile::tempdir().unwrap(); + write_session(root.path()); + let cache = root.path().join("search.db"); + let cache = cache.to_str().unwrap(); + + // The first run fills the cache; the second thaws the session from it. + // Cached documents keep their tool output, so both find it. + for _ in 0..2 { + let found = query(root.path(), &["--tool-output", "--cache", cache]); + assert!(found.contains("[tool_result]"), "{found}"); + } + assert!(query(root.path(), &["--cache", cache]).starts_with("no matches")); +} + +/// Call `search_sessions` over stdio and return its structured result. +fn mcp_search(root: &Path, arguments: &Value) -> Value { + let mut child = txcript(root) + .arg("mcp") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let mut stdin = child.stdin.take().unwrap(); + for message in [ + json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { + "protocolVersion": "2025-06-18", "capabilities": {}, + "clientInfo": {"name": "test", "version": "0"}, + }}), + json!({"jsonrpc": "2.0", "method": "notifications/initialized"}), + json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { + "name": "search_sessions", "arguments": arguments, + }}), + ] { + writeln!(stdin, "{message}").unwrap(); + } + let stdout = BufReader::new(child.stdout.take().unwrap()); + let response = stdout + .lines() + .map(Result::unwrap) + .map(|line| serde_json::from_str::(&line).unwrap()) + .find(|message| message["id"] == 2) + .expect("no search_sessions response"); + drop(stdin); + let _ = child.kill(); + let _ = child.wait(); + assert_eq!(response["result"]["isError"], false, "{response}"); + response["result"]["structuredContent"].clone() +} + +#[test] +fn mcp_search_matches_the_cli_flag() { + let root = tempfile::tempdir().unwrap(); + write_session(root.path()); + + for arguments in [ + json!({"pattern": NEEDLE, "from": "claude-code"}), + json!({"pattern": NEEDLE, "from": "claude-code", "tool_output": false}), + ] { + let result = mcp_search(root.path(), &arguments); + assert_eq!(result["matches"], json!([]), "{arguments}"); + } + + let result = mcp_search( + root.path(), + &json!({"pattern": NEEDLE, "from": "claude-code", "tool_output": true}), + ); + let matches = result["matches"].as_array().unwrap(); + assert_eq!(matches.len(), 1, "{result}"); + assert_eq!(matches[0]["session"]["id"], SESSION); + let hits = matches[0]["hits"].as_array().unwrap(); + assert_eq!(hits.len(), 1, "{result}"); + assert_eq!(hits[0]["origin"], "tool_result"); + assert_eq!(hits[0]["line"], format!("error: {NEEDLE} in libfoo.a")); +} diff --git a/docs/usage.md b/docs/usage.md index ca51e64..625360c 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -69,10 +69,13 @@ txcript query # interactive picker; Enter continues [--model ] # only sessions using this model (case-insensitive substring) [--since ] # only sessions started at or after [--until ] # only sessions started at or before + [--tool-output] # also search tool output ``` A pattern matches literally and case-insensitively: `relay bug` finds lines containing that exact text, spaces and all. +Tool output (command output, file contents a tool read, other tool responses) is not searched by default, since it is often long and full of incidental matches. Pass `--tool-output` to search it too. + `--since` / `--until` accept RFC3339 timestamps (`2025-06-01T00:00:00Z`) or bare dates (`2025-06-01`); bare dates mean local midnight (for `--since`) or end of local day (for `--until`). In the picker, type to filter, arrows / ctrl-p/n to move, Enter to continue the selection in its own harness (or `--with`), Esc to cancel. Every row shows which kind of content matched: user text, assistant text, thinking, tool use, tool output, or session metadata. @@ -88,10 +91,10 @@ txcript mcp # stdio transport Exposes three read-only tools; their optional filters match the CLI: - `list_sessions(from?, cwd?, limit?, offset?)` -- `search_sessions(pattern, from?, cwd?, git_branch?, model?, since?, until?)` +- `search_sessions(pattern, from?, cwd?, git_branch?, model?, since?, until?, tool_output?)` - `read_session(id, from?)` -\* Omitting `from` includes every harness; omitting `cwd` applies no directory filter. Sessions without a recorded working directory match only when `cwd` is omitted. `git_branch` is exact; `model` is a case-insensitive substring; `since`/`until` are RFC3339 strings or bare dates. +\* Omitting `from` includes every harness; omitting `cwd` applies no directory filter. Sessions without a recorded working directory match only when `cwd` is omitted. `git_branch` is exact; `model` is a case-insensitive substring; `since`/`until` are RFC3339 strings or bare dates; `tool_output: true` matches `--tool-output`. `list_sessions` pages with `limit` and `offset` and reports the total before paging; the live Claude Chat and ChatGPT sources are never listed. `read_session` takes the same `#range` suffix as `view` and returns the same compact text; a read too large to return whole is refused with suggested sub-ranges. `--cache` applies to the server too. diff --git a/tests/integration/search.rs b/tests/integration/search.rs index e3a7a47..e90a48a 100644 --- a/tests/integration/search.rs +++ b/tests/integration/search.rs @@ -543,3 +543,429 @@ fn query_filter_cwd_path_prefix() { q.cwd = None; assert_eq!(index.query(&q).len(), 2); } + +// ── tool output ───────────────────────────────────────────────────────────── +// +// Tool output is indexed like every other origin but searched only when a +// query selects `Origin::ToolResult` — what `txcript query --tool-output` and +// the MCP `tool_output` argument do. These pin that the wider scope finds +// tool output with the same literal, case-insensitive semantics as message +// text, and changes nothing else. + +/// The query `txcript query` and the MCP search tool build (the CLI crate's +/// `user_query`): one literal needle, case-insensitive, tool output searched +/// only when asked. +fn cli_query(pattern: &str, tool_output: bool) -> Query { + let mut q = Query::substring(pattern); + q.case = txcript::search::Case::Insensitive; + if tool_output { + q.origins = Origin::ALL.to_vec(); + } + q +} + +fn bash(id: &str, command: &str) -> Block { + Block::ToolUse { + id: id.to_string(), + tool: Tool::Bash { + command: command.to_string(), + workdir: None, + timeout_ms: None, + description: None, + run_in_background: false, + }, + } +} + +fn output(id: &str, out: &str) -> Block { + Block::ToolResult { + tool_use_id: id.to_string(), + content: ToolOutput::Text(out.to_string()), + is_error: false, + } +} + +/// A session with `needle` in exactly one place: the user's words, the +/// assistant's, or a tool's output. +fn session_with(id: &str, secs: i64, place: Origin, needle: &str) -> Transcript { + let in_place = |origin: Origin, filler: &str| { + if origin == place { + format!("{filler} {needle} {filler}") + } else { + filler.to_string() + } + }; + Transcript::new( + meta(id, secs), + vec![ + message( + Role::User, + vec![text(&in_place(Origin::User, "please look"))], + ), + message( + Role::Assistant, + vec![ + text(&in_place(Origin::Assistant, "checking")), + bash("t1", "make check"), + ], + ), + message( + Role::User, + vec![output("t1", &in_place(Origin::ToolResult, "ok"))], + ), + ], + ) +} + +#[test] +fn message_text_matches_are_the_same_with_tool_output() { + for place in [Origin::User, Origin::Assistant] { + let t = session_with("a", 0, place, "quokka-7"); + let narrow = search(&t, &cli_query("quokka-7", false)); + let wide = search(&t, &cli_query("quokka-7", true)); + assert_eq!(narrow.len(), 1, "{place:?}"); + assert_eq!(narrow[0].origin, place); + assert_eq!(wide, narrow, "{place:?}"); + } + // The other non-tool-output origins, from the shared fixture. + let t = rich_transcript("a", 0); + for pattern in [ + "flaky websocket", + "races the handshake", + "--nocapture", + "relay-protocol-v6", + ] { + assert_eq!( + search(&t, &cli_query(pattern, true)), + search(&t, &cli_query(pattern, false)), + "{pattern}" + ); + } +} + +#[test] +fn text_only_in_tool_output_needs_tool_output() { + let t = session_with("a", 0, Origin::ToolResult, "quokka-7"); + assert_eq!(search(&t, &cli_query("quokka-7", false)), Vec::new()); + + let hits = search(&t, &cli_query("quokka-7", true)); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].origin, Origin::ToolResult); + assert_eq!(hits[0].line, "ok quokka-7 ok"); + // Hot and cold agree under the wider scope too. + let mut index = Index::new(); + index.insert(key(HarnessId::ClaudeCode, "a"), &t); + let hot = index.query(&cli_query("quokka-7", true)); + assert_eq!(hot.len(), 1); + assert_eq!(hot[0].hits, hits); +} + +#[test] +fn only_the_tool_output_that_matches_is_hit() { + let t = Transcript::new( + meta("a", 0), + vec![ + message( + Role::Assistant, + vec![ + bash("t1", "ls"), + bash("t2", "cat Cargo.toml"), + bash("t3", "git status"), + ], + ), + message( + Role::User, + vec![ + output("t1", "Cargo.toml\nsrc"), + output("t2", "[package]\nname = \"wombat-engine\""), + output("t3", "nothing to commit"), + ], + ), + ], + ); + let hits = search(&t, &cli_query("wombat-engine", true)); + assert_eq!(hits.len(), 1); + assert_eq!((hits[0].span.clone(), hits[0].block), (Span(1..2), 1)); + let fragment = t.fragment(&hits[0].span).unwrap(); + match &fragment[0].content[hits[0].block] { + Block::ToolResult { + tool_use_id, + content: ToolOutput::Text(out), + .. + } => { + assert_eq!(tool_use_id, "t2"); + assert!(out.contains(&hits[0].line)); + } + other => panic!("expected t2's output, got {other:?}"), + } +} + +#[test] +fn tool_output_matches_inside_surrounding_text() { + let t = Transcript::new( + meta("a", 0), + vec![message( + Role::User, + vec![output( + "t1", + "running 3 tests\n at parser::step (src/lexer.rs:42) \ntest result: FAILED", + )], + )], + ); + let hits = search(&t, &cli_query("lexer.rs:42", true)); + assert_eq!(hits.len(), 1); + // The matched line, trailing whitespace trimmed, as for message text. + assert_eq!(hits[0].line, " at parser::step (src/lexer.rs:42)"); + assert_eq!(hits[0].highlights, vec![23..34]); +} + +#[test] +fn sessions_matching_by_tool_output_and_by_text_are_all_found() { + let mut index = Index::new(); + let both = Transcript::new( + meta("both", 300), + vec![ + message(Role::User, vec![text("why does kestrel-9 fail")]), + message(Role::User, vec![output("t1", "error: kestrel-9 timed out")]), + ], + ); + index.insert( + key(HarnessId::ClaudeCode, "text"), + &session_with("text", 0, Origin::User, "kestrel-9"), + ); + index.insert( + key(HarnessId::Codex, "tool"), + &session_with("tool", 200, Origin::ToolResult, "kestrel-9"), + ); + index.insert( + key(HarnessId::ClaudeCode, "asst"), + &session_with("asst", 100, Origin::Assistant, "kestrel-9"), + ); + index.insert(key(HarnessId::Pi, "both"), &both); + index.insert( + key(HarnessId::ClaudeCode, "none"), + &session_with("none", 400, Origin::User, "falcon"), + ); + let ids = + |q: &Query| -> Vec { index.query(q).iter().map(|m| m.key.id.clone()).collect() }; + + // A literal needle scores the same wherever it sits, so ties rank + // newest-first under either scope. + assert_eq!( + ids(&cli_query("kestrel-9", false)), + ["both", "asst", "text"] + ); + assert_eq!( + ids(&cli_query("kestrel-9", true)), + ["both", "tool", "asst", "text"] + ); + // A session matching both ways shows both lines, in transcript order. + let wide = index.query(&cli_query("kestrel-9", true)); + let origins: Vec = wide[0].hits.iter().map(|h| h.origin).collect(); + assert_eq!(origins, [Origin::User, Origin::ToolResult]); +} + +#[test] +fn absent_text_and_empty_patterns_are_unchanged_by_tool_output() { + let mut index = Index::new(); + index.insert( + key(HarnessId::ClaudeCode, "old"), + &session_with("old", 0, Origin::ToolResult, "quokka-7"), + ); + index.insert( + key(HarnessId::Codex, "new"), + &session_with("new", 1000, Origin::User, "quokka-7"), + ); + + for tool_output in [false, true] { + let q = cli_query("not-in-any-session", tool_output); + assert_eq!(index.query(&q).len(), 0, "tool_output={tool_output}"); + assert_eq!( + search(&session_with("a", 0, Origin::ToolResult, "quokka-7"), &q), + Vec::new() + ); + + // An empty pattern lists every document, newest first, with no hits; + // the one-shot search matches nothing. + let q = cli_query(" ", tool_output); + let listed = index.query(&q); + let ids: Vec<&str> = listed.iter().map(|m| m.key.id.as_str()).collect(); + assert_eq!(ids, ["new", "old"]); + assert!(listed.iter().all(|m| m.hits.is_empty() && m.score == 0)); + assert_eq!( + search(&session_with("a", 0, Origin::ToolResult, "quokka-7"), &q), + Vec::new() + ); + } +} + +/// Case folding and Unicode in tool output follow the rules for message +/// text, whatever those are: one per-character case fold, no diacritic +/// folding in substring mode, character-indexed highlights. +#[test] +fn tool_output_case_and_unicode_follow_message_text() { + let line = "Naïve ÉCHEC: 日本語 Überprüfung failed"; + let as_text = Transcript::new(meta("a", 0), vec![message(Role::User, vec![text(line)])]); + let as_output = Transcript::new( + meta("b", 0), + vec![message(Role::User, vec![output("t1", line)])], + ); + for (pattern, found) in [ + ("naïve échec", true), + ("NAÏVE", true), + ("überprüfung", true), + ("日本語", true), + // Substring mode does not fold diacritics, in any origin. + ("naive", false), + ("echec", false), + ("ÜBERPRUFUNG", false), + ] { + let from_text = search(&as_text, &cli_query(pattern, true)); + let from_output = search(&as_output, &cli_query(pattern, true)); + assert_eq!(from_text.len(), usize::from(found), "{pattern}"); + assert_eq!(from_output.len(), from_text.len(), "{pattern}"); + for (t, o) in from_text.iter().zip(&from_output) { + assert_eq!((&t.line, &t.highlights), (&o.line, &o.highlights)); + assert_eq!(o.origin, Origin::ToolResult); + } + } + let hits = search(&as_output, &cli_query("日本語 überprüfung", true)); + assert_eq!(hits.len(), 1); + // "Naïve ÉCHEC: " is 13 characters; highlights count characters. + assert_eq!(hits[0].highlights, vec![13..28]); +} + +/// Tool outputs that are empty, blank, JSON rather than text, or answer no +/// known tool call neither panic nor produce stray hits. +#[test] +fn odd_tool_outputs_are_searched_safely() { + let result = |id: &str, content: ToolOutput, is_error: bool| Block::ToolResult { + tool_use_id: id.to_string(), + content, + is_error, + }; + let t = Transcript::new( + meta("a", 0), + vec![ + message(Role::Assistant, vec![bash("t1", "true")]), + message( + Role::User, + vec![ + result("t1", ToolOutput::Text(String::new()), false), + result("t2", ToolOutput::Text(" \n\t\n".to_string()), true), + result("t3", ToolOutput::Json(serde_json::Value::Null), false), + result( + "orphan", + ToolOutput::Json(serde_json::json!({"rows": [{"name": "ibis-3"}]})), + false, + ), + ], + ), + // A tool result where a harness put it on the assistant turn. + message( + Role::Assistant, + vec![result( + "t4", + ToolOutput::Text("ibis-3 done".to_string()), + false, + )], + ), + ], + ); + let hits = search(&t, &cli_query("ibis-3", true)); + let found: Vec<(usize, usize, Origin)> = hits + .iter() + .map(|h| (h.span.0.start, h.block, h.origin)) + .collect(); + assert_eq!( + found, + [(1, 3, Origin::ToolResult), (2, 0, Origin::ToolResult)] + ); + assert_eq!(search(&t, &cli_query("ibis-3", false)), Vec::new()); + + let mut index = Index::new(); + index.insert(key(HarnessId::ClaudeCode, "a"), &t); + assert_eq!(index.query(&cli_query("ibis-3", true))[0].hits, hits); +} + +/// Several dozen generated sessions with large, uneven tool outputs — +/// enough lines that `Index::query` shards scoring across threads and +/// enough matches that it shards highlighting too. A third of the sessions +/// mention the needle only in tool output, a third only in user text. +#[test] +fn many_sessions_with_large_tool_outputs() { + const SESSIONS: u64 = 96; + // Fixed-seed xorshift: output sizes and needle positions vary, the same + // way on every run. + let mut seed = 0x9e37_79b9_7f4a_7c15_u64; + let mut next = move |bound: u64| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed % bound + }; + let mut index = Index::new(); + for i in 0..SESSIONS { + let id = format!("s{i:03}"); + let lines = 600 + next(200); + let at = next(lines); + let out: String = (0..lines) + .map(|l| { + if i.is_multiple_of(3) && l == at { + format!("warning: pelican-42 deprecated (line {l})\n") + } else { + format!(" Compiling crate-{l} v0.{i}.0\n") + } + }) + .collect(); + let ask = if i % 3 == 1 { + "is pelican-42 still used?" + } else { + "build it" + }; + let t = Transcript::new( + meta(&id, i64::try_from(i).unwrap()), + vec![ + message(Role::User, vec![text(ask)]), + message(Role::Assistant, vec![bash("t1", "cargo build")]), + message(Role::User, vec![output("t1", &out)]), + ], + ); + index.insert(key(HarnessId::ClaudeCode, &id), &t); + } + assert!(index.lines() >= 64 * 1024, "{} lines", index.lines()); + + let ids = + |q: &Query| -> Vec { index.query(q).iter().map(|m| m.key.id.clone()).collect() }; + // Equal literal scores: newest (highest-numbered) first. + let expect = |keep: &dyn Fn(u64) -> bool| -> Vec { + (0..SESSIONS) + .rev() + .filter(|&i| keep(i)) + .map(|i| format!("s{i:03}")) + .collect() + }; + + let narrow = cli_query("pelican-42", false); + assert_eq!(ids(&narrow), expect(&|i| i % 3 == 1)); + + let wide = cli_query("PELICAN-42", true); + let all = ids(&wide); + assert_eq!(all, expect(&|i| i % 3 != 2)); + assert_eq!(ids(&wide), all, "repeat queries agree"); + for m in index.query(&wide) { + let n: u64 = m.key.id[1..].parse().unwrap(); + let origin = if n.is_multiple_of(3) { + Origin::ToolResult + } else { + Origin::User + }; + assert_eq!(m.hits.len(), 1, "{}", m.key.id); + assert_eq!(m.hits[0].origin, origin, "{}", m.key.id); + } + + // The CLI's 20-document cap keeps the top of the same ranking. + let mut capped = wide.clone(); + capped.limit = Some(20); + assert_eq!(ids(&capped), all[..20]); +}