Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions crates/jp_cli/src/cmd/conversation/grep_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1311,6 +1311,28 @@ fn tool_results_are_searched() {
)]);
}

#[test]
fn scope_structured_searches_serialized_json() {
let id = make_id(9500);
let (mut ctx, out) = setup(vec![(
id,
turn(vec![ConversationEvent::new(
ChatResponse::structured(json!({ "name": "Alice" })),
ts(),
)]),
)]);

let grep = Grep {
scopes: vec![Scope::Structured],
..grep("Alice")
};
// The persisted value is a JSON object, so the searchable text is the
// pretty-printed serialization rather than the value itself.
assert_eq!(run(grep, &mut ctx, &out), [format!(
"{id}:1:structured:m: \"name\": \"Alice\""
)]);
}

#[test]
fn expand_scopes_empty_is_all() {
assert_eq!(expand_scopes(&[]).len(), ConcreteScope::ALL.len());
Expand Down
6 changes: 3 additions & 3 deletions crates/jp_cli/src/cmd/conversation/use_.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,10 @@ pub(crate) struct Use {
#[command(flatten)]
target: PositionalIds<true, false>,

/// Restrict picker candidates to conversations whose title or chat content
/// matches.
/// Restrict picker candidates to conversations containing the pattern.
///
/// Substring match.
/// Substring match against the title, chat text, reasoning, structured
/// output, tool calls, tool results, and inquiry questions.
/// Case-insensitive unless the pattern contains an uppercase character
/// (smart-case).
/// Composable with target keywords (`?`, `?p`, `?s`, `?a`) and with
Expand Down
51 changes: 22 additions & 29 deletions crates/jp_cli/src/shared/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,8 @@ pub(crate) fn event_scope(kind: &EventKind) -> Option<ConcreteScope> {

/// Extract all searchable text lines from an event.
///
/// Lines may be borrowed from the event or owned (tool call arguments are
/// serialized on demand).
/// Lines may be borrowed from the event or owned (tool call arguments and
/// structured responses are serialized on demand).
pub(crate) fn event_lines(kind: &EventKind) -> Vec<Cow<'_, str>> {
match kind {
EventKind::ChatRequest(req) => req.content.lines().map(Cow::Borrowed).collect(),
Expand All @@ -98,12 +98,21 @@ pub(crate) fn event_lines(kind: &EventKind) -> Vec<Cow<'_, str>> {
EventKind::ChatResponse(ChatResponse::Reasoning { reasoning }) => {
reasoning.lines().map(Cow::Borrowed).collect()
}
EventKind::ChatResponse(ChatResponse::Structured { data }) => data
.as_str()
.iter()
.flat_map(|text| text.lines())
.map(Cow::Borrowed)
.collect(),
EventKind::ChatResponse(ChatResponse::Structured { data }) => match data.as_str() {
// A response whose JSON failed to parse is kept as a raw string.
// Searching it verbatim avoids re-quoting and escaping it.
Some(text) => text.lines().map(Cow::Borrowed).collect(),
// Anything else was parsed into a `Value` before it was persisted,
// so its text has to be rebuilt. Pretty-printed for the same reason
// tool call arguments are.
None => serde_json::to_string_pretty(data)
.map(|json| {
json.lines()
.map(|line| Cow::Owned(line.to_owned()))
.collect()
})
.unwrap_or_default(),
},
EventKind::ToolCallRequest(req) => {
let mut out: Vec<Cow<'_, str>> = req.name.lines().map(Cow::Borrowed).collect();
if !req.arguments.is_empty() {
Expand Down Expand Up @@ -319,17 +328,13 @@ impl Matcher {
}
}

/// Filter conversation IDs to those whose title or chat content contains
/// `pattern` as a literal.
/// Filter conversation IDs to those containing `pattern` as a literal.
///
/// Smart-case: case-insensitive unless `pattern` contains an uppercase
/// character.
/// Searched scopes: title, user, assistant, reasoning, and structured.
/// Deliberately narrower than `conversation grep`, which also reads tool calls,
/// tool results, and inquiries: this backs `conversation use --grep`, whose job
/// is finding a conversation by what was *said* in it.
/// The scope set is fixed because the flag has no way to express one; reach for
/// `conversation grep` to search tool traffic, then pass the ID back to `use`.
/// Every searchable scope is read: title, chat text, reasoning, structured
/// output, tool call names and arguments, tool results, and inquiry questions.
/// That is the same set `conversation grep` searches by default.
/// Runs in parallel via rayon and short-circuits on the first match per
/// conversation.
///
Expand All @@ -350,7 +355,7 @@ pub(crate) fn filter_ids(
.collect())
}

/// Whether the conversation's title or chat content matches.
/// Whether any of the conversation's searchable text matches.
fn id_matches(ctx: &Ctx, id: ConversationId, matcher: &Matcher) -> bool {
let Ok(handle) = ctx.workspace.acquire_conversation(&id) else {
return false;
Expand All @@ -371,18 +376,6 @@ fn id_matches(ctx: &Ctx, id: ConversationId, matcher: &Matcher) -> bool {
};

for event in events.iter() {
let Some(scope) = event_scope(&event.event.kind) else {
continue;
};
if !matches!(
scope,
ConcreteScope::User
| ConcreteScope::Assistant
| ConcreteScope::Reasoning
| ConcreteScope::Structured
) {
continue;
}
for line in event_lines(&event.event.kind) {
if matcher.is_match(&line) {
return true;
Expand Down
92 changes: 84 additions & 8 deletions crates/jp_cli/src/shared/search_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ use chrono::{TimeZone as _, Utc};
use jp_config::AppConfig;
use jp_conversation::{
Conversation, ConversationEvent, ConversationId, EventKind,
event::{ChatRequest, ChatResponse, ToolCallResponse},
event::{
ChatRequest, ChatResponse, InquiryQuestion, InquiryRequest, InquirySource, ToolCallRequest,
ToolCallResponse,
},
};
use jp_printer::{OutputFormat, Printer};
use jp_workspace::Workspace;
Expand Down Expand Up @@ -82,6 +85,33 @@ fn event_lines_chat_response_reasoning() {
assert_eq!(collect_lines(&kind), vec!["thinking...".to_string()]);
}

#[test]
fn event_lines_chat_response_structured_object() {
// A structured response is parsed into a `Value` before it is persisted, so
// the searchable text has to be re-serialized. Pretty-printed, matching how
// tool call arguments are handled.
let kind = EventKind::ChatResponse(ChatResponse::structured(serde_json::json!({
"name": "Alice"
})));

assert_eq!(collect_lines(&kind), vec![
"{".to_string(),
" \"name\": \"Alice\"".to_string(),
"}".to_string(),
]);
}

#[test]
fn event_lines_chat_response_structured_string_is_verbatim() {
// A response whose JSON failed to parse is preserved as a raw string. It is
// searched as-is rather than re-quoted.
let kind = EventKind::ChatResponse(ChatResponse::structured(serde_json::Value::String(
"not json {".to_owned(),
)));

assert_eq!(collect_lines(&kind), vec!["not json {".to_string()]);
}

#[test]
fn event_lines_turn_start_is_empty() {
let kind = EventKind::TurnStart(jp_conversation::event::TurnStart);
Expand Down Expand Up @@ -137,9 +167,9 @@ fn a_poisoned_matcher_stops_matching() {

// --- filter_ids -------------------------------------------------------------
//
// `filter_ids` uses fixed scopes (title + chat) and smart-case matching, and
// returns matching IDs without building hit metadata. These tests pin the
// scope set and the smart-case rule.
// `filter_ids` searches every scope with smart-case matching, and returns
// matching IDs without building hit metadata. These tests pin the scope set and
// the smart-case rule.

/// The matching IDs, for a pattern expected to compile.
fn matching(ctx: &Ctx, ids: &[ConversationId], pattern: &str) -> Vec<ConversationId> {
Expand Down Expand Up @@ -201,9 +231,7 @@ fn filter_ids_matches_title() {
}

#[test]
fn filter_ids_ignores_tool_call_response() {
// Tool call results sit outside the chat-style scope set. A match in
// tool output should NOT pull the conversation into the picker.
fn filter_ids_matches_tool_call_response() {
let id = make_id(20_500);
let ctx = setup_ctx_with_events(vec![(id, vec![ConversationEvent::new(
ToolCallResponse {
Expand All @@ -213,7 +241,55 @@ fn filter_ids_ignores_tool_call_response() {
Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap(),
)])]);

assert!(matching(&ctx, &[id], "secret-keyword").is_empty());
assert_eq!(matching(&ctx, &[id], "secret-keyword"), vec![id]);
}

#[test]
fn filter_ids_matches_tool_call_request_arguments() {
// Arguments are serialized on demand rather than stored as text, so this
// pins the one scope whose searchable content doesn't already exist as a
// string in the event.
let id = make_id(20_510);
let mut arguments = serde_json::Map::new();
arguments.insert(
"pattern".to_owned(),
serde_json::Value::String("integer_literal_enum_has_integer_type".to_owned()),
);
let ctx = setup_ctx_with_events(vec![(id, vec![ConversationEvent::new(
ToolCallRequest::new("tc1".into(), "fs_grep_files".into(), arguments),
Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap(),
)])]);

assert_eq!(
matching(&ctx, &[id], "integer_literal_enum_has_integer_type"),
vec![id]
);
}

#[test]
fn filter_ids_matches_structured_object() {
let id = make_id(20_530);
let ctx = setup_ctx_with_events(vec![(id, vec![ConversationEvent::new(
ChatResponse::structured(serde_json::json!({ "name": "Alice" })),
Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap(),
)])]);

assert_eq!(matching(&ctx, &[id], "Alice"), vec![id]);
}

#[test]
fn filter_ids_matches_inquiry_question() {
let id = make_id(20_520);
let ctx = setup_ctx_with_events(vec![(id, vec![ConversationEvent::new(
InquiryRequest::new(
"iq1",
InquirySource::Assistant,
InquiryQuestion::text("Which migration strategy should I use?".to_owned()),
),
Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap(),
)])]);

assert_eq!(matching(&ctx, &[id], "migration strategy"), vec![id]);
}

#[test]
Expand Down
Loading