diff --git a/crates/tinytools-agent/src/parse/grammar/tagged.rs b/crates/tinytools-agent/src/parse/grammar/tagged.rs index 66d3dbc..4f01ad6 100644 --- a/crates/tinytools-agent/src/parse/grammar/tagged.rs +++ b/crates/tinytools-agent/src/parse/grammar/tagged.rs @@ -29,7 +29,7 @@ use regex::Regex; use super::{Block, Decoded, Grammar, Probe, ScanMode, find_ci, pending_opener, prefer_pending}; use crate::parse::call_object::{AliasPolicy, read_calls}; use crate::parse::json_values::{ - extract_first_json_value_with_end, extract_json_values, find_json_end, strip_leading_close_tags, + extract_first_json_value_with_end, extract_json_values, find_json_end, }; use crate::repair::json::{recover_object, strip_code_fence}; use crate::types::{CallSource, ParseOptions, ParsedToolCall}; @@ -117,20 +117,64 @@ impl Tagged { let Some(opener) = next_opener(text, from) else { return Probe::None; }; - let after = &text[opener.body_start..]; + let mut body_start = opener.body_start; + // How many extra openers a doubled block skipped, so the matching + // number of extra closers — never an unrelated closing tag such as + // `` — can be swallowed below. `DeepSeek` V4 doubles both the + // opener and the closer under a code dialect: + // `\n\nNAME(...)\n\n`. + let mut skipped = 0usize; let close = match opener.kind { - OpenerKind::Tag => TAG_RE - .as_ref() - .and_then(|re| re.find(after)) - .map(|m| (m.start(), m.end())), - OpenerKind::Invoke => after.find("").map(|i| (i, i + "".len())), - OpenerKind::Fence => fence_close(after), + OpenerKind::Tag => { + // Positional pairing means a doubled opener would otherwise + // close the first tag on an empty body and lose the call. An + // opener followed by nothing but whitespace is the same + // block starting again, so the scan moves past it. + let re = TAG_RE.as_ref(); + loop { + let after = &text[body_start..]; + let Some(m) = re.and_then(|re| re.find(after)) else { + break None; + }; + let is_opener = !is_closing_marker(m.as_str()); + if is_opener && after[..m.start()].trim().is_empty() { + body_start += m.end(); + skipped += 1; + continue; + } + break Some((m.start(), m.end())); + } + } + OpenerKind::Invoke => { + let after = &text[body_start..]; + after.find("").map(|i| (i, i + "".len())) + } + OpenerKind::Fence => fence_close(&text[body_start..]), }; + let after = &text[body_start..]; if let Some((body_end, close_end)) = close { let body = &after[..body_end]; - let end = opener.body_start + close_end; + let rest = &after[close_end..]; + // Swallow only the closers a doubled opener left behind — never + // an unrelated closing tag such as `` — so no stray + // `` survives into the visible text while narrative + // markup after a normal call is left untouched. + let end = if opener.kind == OpenerKind::Tag && skipped > 0 { + match swallow_extra_closers(rest, skipped, mode) { + Some(consumed) => body_start + close_end + consumed, + // Streaming: more input could still bring the matching + // closer, so the block is not safe to finalize yet. + None => { + return Probe::Pending { + start: opener.start, + }; + } + } + } else { + body_start + close_end + }; let calls = decode_body(body, options); let decoded = if calls.is_empty() { Decoded::Malformed { @@ -152,6 +196,18 @@ impl Tagged { }; } + // Batch: an opener with nothing after it is a call the model started + // and never wrote (a truncated or abandoned block). There is nothing + // to recover and nothing worth showing, so it is dropped rather than + // left in the visible text as a bare ``. + if opener.kind == OpenerKind::Tag && after.trim().is_empty() { + return Probe::Found(Block { + start: opener.start, + end: text.len(), + decoded: Decoded::Malformed { body_chars: 0 }, + }); + } + // Batch: no closer. Recover a balanced JSON body if one starts here. let recovered = find_json_end(after) .and_then(|json_end| { @@ -168,9 +224,12 @@ impl Tagged { CallSource::TaggedJson, ); if !calls.is_empty() { - let rest = &after[consumed..]; - let stripped = strip_leading_close_tags(rest); - let end = text.len() - stripped.len(); + // No tag-family marker exists anywhere after this opener (the + // TAG_RE scan above found none), so nothing here is protocol + // furniture to clean up — stopping at the JSON boundary + // leaves any trailing markup, tool-call-related or not, in + // the narrative rather than guessing which closer it was. + let end = body_start + consumed; return Probe::Found(Block { start: opener.start, end, @@ -186,6 +245,78 @@ impl Tagged { } } +/// Whether a tag-family marker is a closer (``, `<|/tool_call|>`). +/// Skips the same whitespace class `TAG_RE`'s `\s` does (not just space and +/// tab), so a marker like `<\n/tool_call>` — which the regex matches as one +/// marker — is still recognized as a closer here. +fn is_closing_marker(marker: &str) -> bool { + marker[1..] + .trim_start_matches(|c: char| c == '|' || c.is_whitespace()) + .starts_with('/') +} + +/// Canonical closer spellings [`swallow_extra_closers`] holds a partial +/// match of in stream mode. Not exhaustive of everything `TAG_RE` accepts +/// (arbitrary interleaved pipes and whitespace) — the same practical +/// trade-off [`pending_opener`]'s literal list already makes for openers. +const CLOSER_PREFIXES: &[&str] = &["` yet and is a case-insensitive prefix of one of [`CLOSER_PREFIXES`]. +fn could_still_become_a_closer(trimmed: &str) -> bool { + if trimmed.contains('>') { + return false; + } + CLOSER_PREFIXES.iter().any(|literal| { + let n = trimmed.len().min(literal.len()); + trimmed.is_char_boundary(n) && trimmed[..n].eq_ignore_ascii_case(&literal[..n]) + }) +} + +/// Swallows up to `max` tag-family closers from the front of `rest` +/// (whitespace between them ignored), returning the byte count consumed. +/// Only a recognized closer — matched by [`TAG_RE`], the same grammar as +/// every opener — is ever eaten, so unrelated markup such as `` is +/// left for the narrative. In [`ScanMode::Stream`], `None` means the text +/// ends, or breaks off mid-marker, before it is clear whether another closer +/// is still coming, so the caller must hold the block back rather than +/// finalize it early and let a partial marker such as `` tail arrives in a later fragment. +fn swallow_extra_closers(rest: &str, max: usize, mode: ScanMode) -> Option { + let re = TAG_RE.as_ref()?; + let mut consumed = 0usize; + for _ in 0..max { + let after = &rest[consumed..]; + let trimmed = after.trim_start(); + let skipped_ws = after.len() - trimmed.len(); + if trimmed.is_empty() { + // Nothing here yet: in batch mode that is simply the end of the + // response, in stream mode a closer could still be on its way. + return if mode == ScanMode::Stream { + None + } else { + Some(consumed) + }; + } + let Some(m) = re.find(trimmed) else { + return if mode == ScanMode::Stream && could_still_become_a_closer(trimmed) { + None + } else { + Some(consumed) + }; + }; + if m.start() != 0 { + return Some(consumed); + } + if !is_closing_marker(m.as_str()) { + return Some(consumed); + } + consumed += skipped_ws + m.end(); + } + Some(consumed) +} + /// The earliest opener at or after `from`: a non-closing tag-family marker, /// the bare `` literal, or a fence opener. fn next_opener(text: &str, from: usize) -> Option { @@ -199,8 +330,7 @@ fn next_opener(text: &str, from: usize) -> Option { if let Some(re) = TAG_RE.as_ref() { for m in re.find_iter(&text[from..]) { // A marker with a slash is a closer, never an opener. - let inner = &m.as_str()[1..]; - if inner.trim_start_matches(['|', ' ', '\t']).starts_with('/') { + if is_closing_marker(m.as_str()) { continue; } consider( diff --git a/crates/tinytools-agent/src/parse/json_values.rs b/crates/tinytools-agent/src/parse/json_values.rs index 108ad67..d0ede48 100644 --- a/crates/tinytools-agent/src/parse/json_values.rs +++ b/crates/tinytools-agent/src/parse/json_values.rs @@ -109,19 +109,3 @@ pub(crate) fn find_json_end(input: &str) -> Option { None } - -/// Drops any run of leading closing tags (``) and the whitespace around -/// them. A truncated closing tag with no `>` consumes the rest. -#[must_use] -pub(crate) fn strip_leading_close_tags(mut input: &str) -> &str { - loop { - let trimmed = input.trim_start(); - if !trimmed.starts_with("') else { - return ""; - }; - input = &trimmed[close_end + 1..]; - } -} diff --git a/crates/tinytools-agent/src/parse/test/engine.rs b/crates/tinytools-agent/src/parse/test/engine.rs index d3e709d..66e26e9 100644 --- a/crates/tinytools-agent/src/parse/test/engine.rs +++ b/crates/tinytools-agent/src/parse/test/engine.rs @@ -1,9 +1,7 @@ //! The scan engine: protected fences, name resolution, helpers, diagnostics. use super::{parse, parse_known}; -use crate::parse::json_values::{ - extract_first_json_value_with_end, find_json_end, strip_leading_close_tags, -}; +use crate::parse::json_values::{extract_first_json_value_with_end, find_json_end}; use crate::parse::protected::fence_ranges; use crate::parse::{ extract_json_values, parse_arguments_value, parse_tool_call_value, @@ -192,13 +190,6 @@ fn json_scanners_cover_common_edge_cases() { assert!(extracted.1 > 0); assert!(extract_first_json_value_with_end("no json here").is_none()); - assert_eq!( - strip_leading_close_tags(" hi "), - "hi " - ); - assert_eq!(strip_leading_close_tags("plain"), "plain"); - assert_eq!(strip_leading_close_tags(" ` + // right after the recovered JSON is narrative, not a stray tool-call + // closer — it must not be swallowed as if it were one. + let (text, calls) = parse("{\"name\":\"echo\",\"arguments\":{}}visible"); + assert_eq!(calls.len(), 1); + assert_eq!(text, "visible"); +} + #[test] fn unclosed_tag_without_json_is_kept_as_text() { let (text, calls) = parse("before not-json"); @@ -415,3 +425,91 @@ fn a_code_call_to_an_unknown_tool_is_not_a_call() { let (_, calls) = parse_tool_calls_with_pformat(response, &echo_registry()); assert!(calls.is_empty()); } + +// ── Doubled tags ──────────────────────────────────────────────────────────── +// +// `DeepSeek` V4 under a code dialect wraps the block twice: +// `\n\nNAME(...)\n\n`. Positional +// pairing used to close the first tag on the empty body and drop the call, +// and the whole thing leaked into the visible reply. + +#[test] +fn a_doubled_opener_is_one_block_and_its_extra_closer_is_swallowed() { + let response = "I'll search.\n\n\n\necho(value=\"kashmir\")\n\n"; + let (narrative, calls) = parse_tool_calls_with_pformat(response, &echo_registry()); + assert_eq!(calls.len(), 1, "{calls:?}"); + assert_eq!(calls[0].name, "echo"); + assert_eq!(calls[0].arguments, serde_json::json!({"value": "kashmir"})); + assert_eq!(narrative, "I'll search."); + assert!(!narrative.contains("tool_call"), "{narrative:?}"); +} + +#[test] +fn a_doubled_opener_around_a_json_body_parses_too() { + let (text, calls) = parse( + "\n\n{\"name\":\"echo\",\"arguments\":{\"value\":\"x\"}}\n\n\nafter", + ); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].arguments, serde_json::json!({"value": "x"})); + assert_eq!(text, "after"); +} + +#[test] +fn two_adjacent_blocks_are_still_two_blocks() { + // The doubled-opener rule only fires on a whitespace-only gap; a real + // body between two openers is still the first block's body. + let text = "{\"name\":\"one\",\"arguments\":{}}{\"name\":\"two\",\"arguments\":{}}"; + let (_, calls) = parse(text); + assert_eq!(calls.len(), 2); + assert_eq!(calls[0].name, "one"); + assert_eq!(calls[1].name, "two"); +} + +#[test] +fn a_doubled_opener_does_not_swallow_an_unrelated_closing_tag() { + // Only the extra `` a doubled opener leaves behind is + // protocol furniture; a real closing tag right after it (``, from + // whatever markup the model echoed) is narrative and must survive. + let (text, calls) = parse( + "\n\n{\"name\":\"echo\",\"arguments\":{}}\n\nvisible", + ); + assert_eq!(calls.len(), 1); + assert_eq!(text, "visible"); +} + +#[test] +fn a_doubled_opener_swallows_a_pipe_form_duplicate_closer() { + // `TAG_RE` matches the pipe-form closer `<|/tool_call|>` too, so the + // doubled-opener path must recognize it as a closer to swallow, not + // leave it dangling as narrative text. + let (text, calls) = parse( + "<|tool_call|>\n<|tool_call|>\n{\"name\":\"echo\",\"arguments\":{}}\n<|/tool_call|>\n<|/tool_call|>\nafter", + ); + assert_eq!(calls.len(), 1, "{calls:?}"); + assert_eq!(text, "after"); +} + +#[test] +fn a_doubled_opener_swallows_a_newline_leaked_duplicate_closer() { + // `TAG_RE`'s `\s` matches any whitespace, not just space and tab, so + // `<\n/tool_call>` is one complete closer marker; `is_closing_marker` + // must classify it as such too, or the extra closer is left behind for + // the narrative to leak. + let (text, calls) = parse( + "\n\n{\"name\":\"echo\",\"arguments\":{}}\n\n<\n/tool_call>\nafter", + ); + assert_eq!(calls.len(), 1, "{calls:?}"); + assert_eq!(text, "after"); +} + +#[test] +fn a_bare_trailing_opener_is_dropped_not_shown() { + // An abandoned block at the end of a reply carries no call and no + // information; showing `` to the user is never right. + let (text, calls) = parse("Let me fetch a few sites directly.\n\n\n"); + assert!(calls.is_empty()); + assert_eq!(text, "Let me fetch a few sites directly."); + // A block with real (if unparseable) content is still kept as text. + let (text, _) = parse("before not-json"); + assert_eq!(text, "before not-json"); +} diff --git a/crates/tinytools-agent/src/stream/test.rs b/crates/tinytools-agent/src/stream/test.rs index 270e899..fa1dbb3 100644 --- a/crates/tinytools-agent/src/stream/test.rs +++ b/crates/tinytools-agent/src/stream/test.rs @@ -252,3 +252,33 @@ fn a_code_call_split_mid_string_is_released_once_and_never_shown() { assert!(!out.contains("echo("), "markup leaked: {out:?}"); assert!(out.contains("Sure.") && out.contains("done")); } + +#[test] +fn a_doubled_blocks_extra_closer_split_across_fragments_never_leaks() { + // The doubled opener's *inner* closer can arrive in one fragment and the + // matching extra closer in the next. The block must stay pending across + // that boundary rather than finalize on the inner closer alone and let + // the later `` fall through as visible text. + let (out, calls) = scrub_all(&[ + "before \n\n{\"name\":\"x\",\"arguments\":{}}\n\n", + " after", + ]); + assert_eq!(out, "before after"); + assert_eq!(calls, 1); + assert!(!out.contains("tool_call"), "{out:?}"); +} + +#[test] +fn a_doubled_blocks_extra_closer_split_mid_marker_never_leaks() { + // The fragment boundary can land *inside* the extra closer itself, not + // just before it: `` in the next. That + // partial marker must be held rather than released as text once its + // first fragment is scanned. + let (out, calls) = scrub_all(&[ + "before \n\n{\"name\":\"x\",\"arguments\":{}}\n\n after", + ]); + assert_eq!(out, "before after"); + assert_eq!(calls, 1); + assert!(!out.contains("tool_"), "{out:?}"); +} diff --git a/crates/tinytools-jev/src/test.rs b/crates/tinytools-jev/src/test.rs index e48a096..b533d30 100644 --- a/crates/tinytools-jev/src/test.rs +++ b/crates/tinytools-jev/src/test.rs @@ -359,3 +359,367 @@ async fn family_then_decide_with_one_family_skips_the_family_stage() { Some("SLACK_SEND_MESSAGE") ); } + +/// Builds a decision from `(key, probability)` pairs, defaulting the fields +/// the family-stage branches below do not exercise. +fn family_decision(pairs: &[(&str, f64)], needs_tool: Option) -> JevDecision { + JevDecision { + probabilities: pairs + .iter() + .map(|(key, value)| ((*key).to_owned(), *value)) + .collect(), + choice_confidence: 0.7, + needs_tool, + input_tokens: Some(10), + attempts: 1, + } +} + +#[tokio::test] +async fn family_then_decide_rejects_reserved_none_family_name() { + let candidates = vec![ + RankCandidate::new("a", "does a").with_family("none"), + RankCandidate::new("b", "does b").with_family("real"), + ]; + let (ranker, _) = ranker_with_strategy( + [("real", 0.5), ("none", 0.5), ("_", 0.0)], + Some(0.9), + JevStrategy::FamilyThenDecide, + ); + let result = ranker + .rank("do it", &RankContext::empty(), &candidates, 2) + .await; + assert!(matches!(result, Err(RankError::InvalidInput { .. }))); +} + +/// A ranker configured with an explicit strategy, reusing the fixed-decision +/// [`FakeEvaluator`]. +fn ranker_with_strategy( + probabilities: [(&str, f64); 3], + needs_tool: Option, + strategy: JevStrategy, +) -> (JevRanker, Arc) { + let evaluator = Arc::new(FakeEvaluator { + decision: JevDecision { + probabilities: probabilities + .into_iter() + .map(|(key, value)| (key.to_owned(), value)) + .collect::>(), + choice_confidence: 0.8, + needs_tool, + input_tokens: Some(10), + attempts: 1, + }, + seen: Mutex::new(vec![]), + }); + ( + JevRanker::new( + evaluator.clone(), + JevRankerConfig::new().with_strategy(strategy), + ), + evaluator, + ) +} + +#[tokio::test] +async fn family_then_decide_rejects_more_families_than_max_candidates() { + let candidates: Vec = (0..=JevRankerConfig::MAX_CANDIDATES) + .map(|i| { + RankCandidate::new(format!("tool_{i}"), "does something") + .with_family(format!("family_{i}")) + }) + .collect(); + let (ranker, _) = ranker_with_strategy( + [("x", 0.5), ("y", 0.5), ("none", 0.0)], + Some(0.9), + JevStrategy::FamilyThenDecide, + ); + let result = ranker + .rank("do it", &RankContext::empty(), &candidates, 2) + .await; + assert!(matches!(result, Err(RankError::InvalidInput { .. }))); +} + +#[tokio::test] +async fn family_then_decide_abstains_when_the_family_stage_prefers_none() { + #[derive(Debug)] + struct AbstainingEvaluator { + seen: Mutex>, + } + #[async_trait::async_trait] + impl JevEvaluator for AbstainingEvaluator { + async fn evaluate(&self, request: &JevRequest) -> Result { + if let Ok(mut seen) = self.seen.lock() { + seen.push(request.clone()); + } + Ok(family_decision( + &[("slack", 0.05), ("gmail", 0.05), ("none", 0.9)], + Some(0.9), + )) + } + } + let evaluator = Arc::new(AbstainingEvaluator { + seen: Mutex::new(vec![]), + }); + let ranker = JevRanker::new( + evaluator.clone(), + JevRankerConfig::new().with_strategy(JevStrategy::FamilyThenDecide), + ); + let ranking = ranker + .rank_detailed("ping alex", &RankContext::empty(), &family_catalogue(), 3) + .await; + assert!( + ranking + .as_ref() + .is_ok_and(|r| r.hits.is_empty() && r.families.is_empty()) + ); + assert!( + ranking + .as_ref() + .is_ok_and(|r| (r.none_probability - 0.9).abs() < 1e-9) + ); + assert_eq!( + evaluator.seen.lock().map_or(0, |s| s.len()), + 1, + "only the family stage ran" + ); +} + +#[tokio::test] +async fn family_then_decide_clears_hits_when_the_chosen_family_says_no_tool_is_needed() { + let evaluator = Arc::new(FakeEvaluator { + decision: family_decision( + &[ + ("SLACK_SEND_MESSAGE", 0.8), + ("SLACK_LIST", 0.1), + ("none", 0.1), + ], + Some(0.2), + ), + seen: Mutex::new(vec![]), + }); + let ranker = JevRanker::new( + evaluator, + JevRankerConfig::new().with_strategy(JevStrategy::FamilyThenDecide), + ); + let only_slack: Vec = family_catalogue() + .into_iter() + .filter(|c| c.family.as_deref() == Some("slack")) + .collect(); + let ranking = ranker + .rank_detailed("ping alex", &RankContext::empty(), &only_slack, 3) + .await; + assert!(ranking.is_ok_and(|r| r.hits.is_empty())); +} + +#[tokio::test] +async fn family_then_decide_drops_a_family_whose_none_beats_every_member() { + let evaluator = Arc::new(FakeEvaluator { + decision: family_decision( + &[ + ("SLACK_SEND_MESSAGE", 0.2), + ("SLACK_LIST", 0.1), + ("none", 0.9), + ], + Some(0.9), + ), + seen: Mutex::new(vec![]), + }); + let ranker = JevRanker::new( + evaluator, + JevRankerConfig::new().with_strategy(JevStrategy::FamilyThenDecide), + ); + let only_slack: Vec = family_catalogue() + .into_iter() + .filter(|c| c.family.as_deref() == Some("slack")) + .collect(); + let ranking = ranker + .rank_detailed("ping alex", &RankContext::empty(), &only_slack, 3) + .await; + assert!(ranking.is_ok_and(|r| r.hits.is_empty())); +} + +#[tokio::test] +async fn family_then_decide_skips_a_member_missing_from_the_decision() { + let evaluator = Arc::new(FakeEvaluator { + decision: family_decision(&[("SLACK_SEND_MESSAGE", 0.8), ("none", 0.05)], Some(0.9)), + seen: Mutex::new(vec![]), + }); + let ranker = JevRanker::new( + evaluator, + JevRankerConfig::new().with_strategy(JevStrategy::FamilyThenDecide), + ); + let only_slack: Vec = family_catalogue() + .into_iter() + .filter(|c| c.family.as_deref() == Some("slack")) + .collect(); + let ranking = ranker + .rank_detailed("ping alex", &RankContext::empty(), &only_slack, 3) + .await + .ok(); + // `SLACK_LIST` has no probability in the decision above, so `merge` must + // skip it rather than panic or fabricate a score for it. + assert_eq!(ranking.as_ref().map(|r| r.hits.len()), Some(1)); + assert_eq!( + ranking + .as_ref() + .and_then(|r| r.hits.first()) + .map(|h| h.key.as_str()), + Some("SLACK_SEND_MESSAGE") + ); +} + +#[tokio::test] +async fn family_then_decide_cuts_an_oversized_family_via_the_retriever() { + let members: Vec = (0..300) + .map(|i| { + let tag = if i < 150 { "alpha" } else { "beta" }; + RankCandidate::new(format!("tool_{i}"), format!("{tag} candidate number {i}")) + }) + .collect(); + let evaluator = Arc::new(FakeEvaluator { + decision: family_decision(&[("none", 0.05)], Some(0.9)), + seen: Mutex::new(vec![]), + }); + let ranker = JevRanker::new( + evaluator.clone(), + JevRankerConfig::new().with_strategy(JevStrategy::FamilyThenDecide), + ); + let _ = ranker + .rank_detailed("alpha", &RankContext::empty(), &members, 3) + .await + .ok(); + let shown = evaluator + .seen + .lock() + .ok() + .and_then(|seen| seen.first().map(|r| r.options.len())) + .unwrap_or(0); + // `none` plus the alpha-tagged half, cut to at most `MAX_CANDIDATES`. + assert!(shown > 1 && shown <= JevRankerConfig::MAX_CANDIDATES + 1); +} + +#[tokio::test] +async fn family_then_decide_falls_back_to_the_first_members_when_retrieval_finds_nothing() { + let members: Vec = (0..300) + .map(|i| RankCandidate::new(format!("tool_{i}"), "shared filler text shared filler")) + .collect(); + let evaluator = Arc::new(FakeEvaluator { + decision: family_decision(&[("none", 0.05)], Some(0.9)), + seen: Mutex::new(vec![]), + }); + let ranker = JevRanker::new( + evaluator.clone(), + JevRankerConfig::new().with_strategy(JevStrategy::FamilyThenDecide), + ); + let _ = ranker + .rank_detailed("shared", &RankContext::empty(), &members, 3) + .await + .ok(); + let shown = evaluator + .seen + .lock() + .ok() + .and_then(|seen| seen.first().map(|r| r.options.len())) + .unwrap_or(0); + // The retriever finds nothing distinguishable, so the fallback keeps the + // first `MAX_CANDIDATES` members (plus `none`) in their original order. + assert_eq!(shown, JevRankerConfig::MAX_CANDIDATES + 1); +} + +#[tokio::test] +async fn family_summary_truncates_past_600_characters() { + #[derive(Debug)] + struct RecordingEvaluator { + seen: Mutex>, + } + #[async_trait::async_trait] + impl JevEvaluator for RecordingEvaluator { + async fn evaluate(&self, request: &JevRequest) -> Result { + if let Ok(mut seen) = self.seen.lock() { + seen.push(request.clone()); + } + Ok(family_decision( + &[("big", 1.0), ("small", 0.0), ("none", 0.0)], + Some(0.9), + )) + } + } + let mut candidates: Vec = (0..12) + .map(|i| { + RankCandidate::new( + format!("TOOL_WITH_A_VERY_DESCRIPTIVE_LONG_NAME_NUMBER_{i:03}_THAT_PADS_LENGTH"), + "does something", + ) + .with_family("big") + }) + .collect(); + candidates.push(RankCandidate::new("SMALL_TOOL", "does one thing").with_family("small")); + let evaluator = Arc::new(RecordingEvaluator { + seen: Mutex::new(vec![]), + }); + let ranker = JevRanker::new( + evaluator.clone(), + JevRankerConfig::new().with_strategy(JevStrategy::FamilyThenDecide), + ); + let _ = ranker + .rank_detailed("do it", &RankContext::empty(), &candidates, 3) + .await + .ok(); + let big_description = evaluator + .seen + .lock() + .ok() + .and_then(|seen| { + seen.first().map(|r| { + r.options + .iter() + .find(|o| o.key == "big") + .map(|o| o.description.clone()) + .unwrap_or_default() + }) + }) + .unwrap_or_default(); + assert!( + big_description.ends_with('…'), + "long family summary should be truncated: {big_description}" + ); + assert!(big_description.chars().count() <= 601); +} + +#[tokio::test] +async fn join_all_resolves_a_future_that_is_pending_on_its_first_poll() { + #[derive(Debug)] + struct YieldingEvaluator; + #[async_trait::async_trait] + impl JevEvaluator for YieldingEvaluator { + async fn evaluate(&self, _request: &JevRequest) -> Result { + // Forces at least one `Poll::Pending` before this future resolves, + // exercising `join_all`'s re-poll loop. + tokio::task::yield_now().await; + Ok(family_decision( + &[("SLACK_SEND_MESSAGE", 0.8), ("none", 0.1)], + Some(0.9), + )) + } + } + let ranker = JevRanker::new( + Arc::new(YieldingEvaluator), + JevRankerConfig::new().with_strategy(JevStrategy::FamilyThenDecide), + ); + let only_slack: Vec = family_catalogue() + .into_iter() + .filter(|c| c.family.as_deref() == Some("slack")) + .collect(); + let ranking = ranker + .rank_detailed("ping alex", &RankContext::empty(), &only_slack, 3) + .await + .ok(); + assert_eq!( + ranking + .as_ref() + .and_then(|r| r.hits.first()) + .map(|h| h.key.as_str()), + Some("SLACK_SEND_MESSAGE") + ); +}