refactor(threads, tool_timeout): extract pure helpers for testability (#784)

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Jwalin Shah <jshah1331@gmail.com>
This commit is contained in:
Jwalin Shah
2026-04-22 23:23:57 -07:00
committed by GitHub
co-authored by Claude Jwalin Shah
parent fc213572c6
commit 1d62d1c0ac
4 changed files with 371 additions and 98 deletions
+1
View File
@@ -6,6 +6,7 @@
pub mod ops;
pub mod schemas;
pub mod title;
pub use schemas::{
all_controller_schemas as all_threads_controller_schemas,
+5 -91
View File
@@ -15,16 +15,16 @@ use crate::openhuman::memory::{
UpsertConversationThreadRequest,
};
use crate::openhuman::providers::{self, ProviderRuntimeOptions};
use crate::openhuman::threads::title::{
build_title_prompt, is_auto_generated_thread_title, sanitize_generated_title,
title_log_fingerprint, THREAD_TITLE_LOG_PREFIX, THREAD_TITLE_MODEL_HINT,
THREAD_TITLE_SYSTEM_PROMPT,
};
use crate::rpc::RpcOutcome;
use serde::Serialize;
use std::collections::BTreeMap;
use std::hash::{Hash, Hasher};
use std::path::PathBuf;
const THREAD_TITLE_LOG_PREFIX: &str = "[threads:title]";
const THREAD_TITLE_MODEL_HINT: &str = "hint:summarize";
const THREAD_TITLE_SYSTEM_PROMPT: &str = "You generate short, specific chat thread titles from the first user message and the assistant reply. Return only the title text. Keep it under 8 words. No quotes. No markdown. No trailing punctuation unless it is part of a proper noun.";
fn request_id() -> String {
uuid::Uuid::new_v4().to_string()
}
@@ -36,12 +36,6 @@ fn counts(entries: impl IntoIterator<Item = (&'static str, usize)>) -> BTreeMap<
.collect()
}
fn title_log_fingerprint(title: &str) -> String {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
title.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
fn envelope<T: Serialize>(
data: T,
counts: Option<BTreeMap<String, usize>>,
@@ -104,86 +98,6 @@ fn record_to_message(record: ConversationMessageRecord) -> ConversationMessage {
}
}
fn is_auto_generated_thread_title(title: &str) -> bool {
let trimmed = title.trim();
let bytes = trimmed.as_bytes();
if bytes.len() < 16 || !trimmed.starts_with("Chat ") {
return false;
}
let month_end = 8;
if bytes.len() <= month_end || !bytes[5..month_end].iter().all(|b| b.is_ascii_alphabetic()) {
return false;
}
if bytes.get(month_end) != Some(&b' ') {
return false;
}
let mut idx = month_end + 1;
let day_start = idx;
while idx < bytes.len() && bytes[idx].is_ascii_digit() {
idx += 1;
}
if idx == day_start || idx - day_start > 2 {
return false;
}
if bytes.get(idx) != Some(&b' ') {
return false;
}
idx += 1;
let hour_start = idx;
while idx < bytes.len() && bytes[idx].is_ascii_digit() {
idx += 1;
}
if idx == hour_start || idx - hour_start > 2 {
return false;
}
if bytes.get(idx) != Some(&b':') {
return false;
}
idx += 1;
if idx + 2 >= bytes.len()
|| !bytes[idx].is_ascii_digit()
|| !bytes[idx + 1].is_ascii_digit()
|| bytes[idx + 2] != b' '
{
return false;
}
idx += 3;
matches!(&trimmed[idx..], "AM" | "PM")
}
fn collapse_whitespace(input: &str) -> String {
input.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn sanitize_generated_title(raw: &str) -> Option<String> {
let line = raw
.lines()
.find(|line| !line.trim().is_empty())
.unwrap_or(raw)
.trim();
let trimmed = line
.trim_matches(|c: char| matches!(c, '"' | '\'' | '`'))
.trim()
.trim_end_matches(['.', '!', '?', ':', ';'])
.trim();
let collapsed = collapse_whitespace(trimmed);
if collapsed.is_empty() {
return None;
}
Some(collapsed.chars().take(80).collect())
}
fn build_title_prompt(user_message: &str, assistant_message: &str) -> String {
format!(
"First user message:\n{user_message}\n\nAssistant reply:\n{assistant_message}\n\nReturn the best thread title."
)
}
/// Lists all conversation threads.
pub async fn threads_list(
_request: EmptyRequest,
+302
View File
@@ -0,0 +1,302 @@
//! Pure helpers for generating and validating conversation thread titles.
//!
//! Extracted from `threads::ops` so the parsing / sanitisation rules can be
//! unit-tested without pulling in `Config`, provider runtime, or RPC wiring.
use std::hash::{Hash, Hasher};
pub const THREAD_TITLE_LOG_PREFIX: &str = "[threads:title]";
pub const THREAD_TITLE_MODEL_HINT: &str = "hint:summarize";
pub const THREAD_TITLE_SYSTEM_PROMPT: &str = "You generate short, specific chat thread titles from the first user message and the assistant reply. Return only the title text. Keep it under 8 words. No quotes. No markdown. No trailing punctuation unless it is part of a proper noun.";
/// Stable 16-hex-char fingerprint of a title — safe for structured logs
/// where we want to correlate events without leaking the raw title text.
pub fn title_log_fingerprint(title: &str) -> String {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
title.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
/// Returns `true` when the title matches the auto-generated placeholder
/// shape used by `thread_create_new` (`"Chat Mon 1 1:23 AM"` / `...PM"`).
///
/// Only placeholder titles are eligible for replacement by the LLM-generated
/// title; user-renamed threads are left untouched.
pub fn is_auto_generated_thread_title(title: &str) -> bool {
let trimmed = title.trim();
let bytes = trimmed.as_bytes();
if bytes.len() < 16 || !trimmed.starts_with("Chat ") {
return false;
}
let month_end = 8;
if bytes.len() <= month_end || !bytes[5..month_end].iter().all(|b| b.is_ascii_alphabetic()) {
return false;
}
if bytes.get(month_end) != Some(&b' ') {
return false;
}
let mut idx = month_end + 1;
let day_start = idx;
while idx < bytes.len() && bytes[idx].is_ascii_digit() {
idx += 1;
}
if idx == day_start || idx - day_start > 2 {
return false;
}
if bytes.get(idx) != Some(&b' ') {
return false;
}
idx += 1;
let hour_start = idx;
while idx < bytes.len() && bytes[idx].is_ascii_digit() {
idx += 1;
}
if idx == hour_start || idx - hour_start > 2 {
return false;
}
if bytes.get(idx) != Some(&b':') {
return false;
}
idx += 1;
if idx + 2 >= bytes.len()
|| !bytes[idx].is_ascii_digit()
|| !bytes[idx + 1].is_ascii_digit()
|| bytes[idx + 2] != b' '
{
return false;
}
idx += 3;
matches!(&trimmed[idx..], "AM" | "PM")
}
/// Collapses any run of whitespace (including newlines/tabs) into single
/// ASCII spaces and trims the result.
pub fn collapse_whitespace(input: &str) -> String {
input.split_whitespace().collect::<Vec<_>>().join(" ")
}
/// Sanitises a raw LLM title completion into a single display-ready line.
///
/// Rules applied (in order):
/// - take the first non-empty line
/// - strip wrapping quotes / backticks
/// - drop trailing `. ! ? : ;`
/// - collapse internal whitespace
/// - truncate to 80 characters
///
/// Returns `None` if the result is empty.
pub fn sanitize_generated_title(raw: &str) -> Option<String> {
let line = raw
.lines()
.find(|line| !line.trim().is_empty())
.unwrap_or(raw)
.trim();
let trimmed = line
.trim_matches(|c: char| matches!(c, '"' | '\'' | '`'))
.trim()
.trim_end_matches(['.', '!', '?', ':', ';'])
.trim();
let collapsed = collapse_whitespace(trimmed);
if collapsed.is_empty() {
return None;
}
Some(collapsed.chars().take(80).collect())
}
/// Builds the user-visible prompt passed to the title-generation model.
pub fn build_title_prompt(user_message: &str, assistant_message: &str) -> String {
format!(
"First user message:\n{user_message}\n\nAssistant reply:\n{assistant_message}\n\nReturn the best thread title."
)
}
#[cfg(test)]
mod tests {
use super::*;
// ── title_log_fingerprint ─────────────────────────────────────
#[test]
fn fingerprint_is_stable_for_same_input() {
assert_eq!(
title_log_fingerprint("hello"),
title_log_fingerprint("hello")
);
}
#[test]
fn fingerprint_differs_for_different_input() {
assert_ne!(
title_log_fingerprint("hello"),
title_log_fingerprint("world")
);
}
#[test]
fn fingerprint_is_sixteen_hex_chars() {
let fp = title_log_fingerprint("anything");
assert_eq!(fp.len(), 16);
assert!(fp.chars().all(|c| c.is_ascii_hexdigit()));
}
// ── is_auto_generated_thread_title ────────────────────────────
#[test]
fn accepts_canonical_placeholder() {
assert!(is_auto_generated_thread_title("Chat Jan 1 1:23 AM"));
assert!(is_auto_generated_thread_title("Chat Dec 31 11:59 PM"));
}
#[test]
fn accepts_single_digit_day_and_hour() {
assert!(is_auto_generated_thread_title("Chat Mar 5 9:07 AM"));
}
#[test]
fn accepts_two_digit_day_and_hour() {
assert!(is_auto_generated_thread_title("Chat Feb 28 10:45 PM"));
}
#[test]
fn tolerates_surrounding_whitespace() {
assert!(is_auto_generated_thread_title(" Chat Jan 1 1:23 AM "));
}
#[test]
fn rejects_empty_and_short_titles() {
assert!(!is_auto_generated_thread_title(""));
assert!(!is_auto_generated_thread_title("Chat"));
assert!(!is_auto_generated_thread_title("Chat Jan 1"));
}
#[test]
fn rejects_non_chat_prefix() {
assert!(!is_auto_generated_thread_title("Thread Jan 1 1:23 AM"));
assert!(!is_auto_generated_thread_title("chat Jan 1 1:23 AM")); // case matters
}
#[test]
fn rejects_numeric_month() {
assert!(!is_auto_generated_thread_title("Chat 01 1 1:23 AM"));
}
#[test]
fn rejects_missing_am_pm() {
assert!(!is_auto_generated_thread_title("Chat Jan 1 1:23"));
assert!(!is_auto_generated_thread_title("Chat Jan 1 1:23 XM"));
}
#[test]
fn rejects_user_renamed_titles() {
assert!(!is_auto_generated_thread_title("Planning the launch party"));
assert!(!is_auto_generated_thread_title(
"Chat with Alice about deploys"
));
}
#[test]
fn rejects_malformed_minutes() {
// Minutes must be exactly two digits followed by a space.
assert!(!is_auto_generated_thread_title("Chat Jan 1 1:2 AM"));
assert!(!is_auto_generated_thread_title("Chat Jan 1 1:234 AM"));
}
// ── collapse_whitespace ────────────────────────────────────────
#[test]
fn collapse_whitespace_normalises_runs() {
assert_eq!(collapse_whitespace(" hello world "), "hello world");
}
#[test]
fn collapse_whitespace_handles_tabs_and_newlines() {
assert_eq!(collapse_whitespace("a\tb\nc d"), "a b c d");
}
#[test]
fn collapse_whitespace_empty_returns_empty() {
assert_eq!(collapse_whitespace(""), "");
assert_eq!(collapse_whitespace(" "), "");
}
// ── sanitize_generated_title ──────────────────────────────────
#[test]
fn sanitize_strips_wrapping_quotes() {
assert_eq!(
sanitize_generated_title("\"Launch plan\"").unwrap(),
"Launch plan"
);
assert_eq!(
sanitize_generated_title("'Debugging deploys'").unwrap(),
"Debugging deploys"
);
assert_eq!(
sanitize_generated_title("`retro notes`").unwrap(),
"retro notes"
);
}
#[test]
fn sanitize_strips_trailing_punctuation() {
assert_eq!(
sanitize_generated_title("Planning session.").unwrap(),
"Planning session"
);
assert_eq!(
sanitize_generated_title("Where are we?").unwrap(),
"Where are we"
);
}
#[test]
fn sanitize_picks_first_nonempty_line() {
let raw = "\n\n First real line \nsecond line\n";
assert_eq!(sanitize_generated_title(raw).unwrap(), "First real line");
}
#[test]
fn sanitize_collapses_internal_whitespace() {
assert_eq!(
sanitize_generated_title("hello world").unwrap(),
"hello world"
);
}
#[test]
fn sanitize_returns_none_for_empty_or_whitespace() {
assert!(sanitize_generated_title("").is_none());
assert!(sanitize_generated_title(" \n\t ").is_none());
assert!(sanitize_generated_title("\"\"").is_none());
}
#[test]
fn sanitize_truncates_to_eighty_chars() {
let long = "a".repeat(200);
let out = sanitize_generated_title(&long).unwrap();
assert_eq!(out.chars().count(), 80);
}
#[test]
fn sanitize_truncates_by_char_count_not_byte_count() {
// Each ✨ is 3 bytes in UTF-8; ensure truncation counts chars, not bytes.
let long: String = std::iter::repeat('✨').take(90).collect();
let out = sanitize_generated_title(&long).unwrap();
assert_eq!(out.chars().count(), 80);
}
// ── build_title_prompt ────────────────────────────────────────
#[test]
fn prompt_contains_both_messages_and_instruction() {
let prompt = build_title_prompt("hello", "hi there");
assert!(prompt.contains("First user message:\nhello"));
assert!(prompt.contains("Assistant reply:\nhi there"));
assert!(prompt.contains("Return the best thread title"));
}
}
+63 -7
View File
@@ -7,16 +7,26 @@ use std::time::Duration;
const DEFAULT_SECS: u64 = 120;
const MAX_SECS: u64 = 3600;
const ENV_VAR: &str = "OPENHUMAN_TOOL_TIMEOUT_SECS";
/// Parse a raw env-var value into a bounded timeout.
///
/// Testable split from [`resolved_secs`]: this function is pure and never
/// touches global state, so unit tests can exercise every path without
/// racing on `OnceLock` or needing to mutate the process environment.
///
/// - `None` or a non-numeric string returns [`DEFAULT_SECS`].
/// - Values outside `1..=MAX_SECS` are rejected (returns [`DEFAULT_SECS`]).
/// - Valid values pass through unchanged.
pub fn parse_tool_timeout_secs(raw: Option<&str>) -> u64 {
raw.and_then(|s| s.parse::<u64>().ok())
.filter(|&n| (1..=MAX_SECS).contains(&n))
.unwrap_or(DEFAULT_SECS)
}
fn resolved_secs() -> u64 {
static SECS: OnceLock<u64> = OnceLock::new();
*SECS.get_or_init(|| {
std::env::var("OPENHUMAN_TOOL_TIMEOUT_SECS")
.ok()
.and_then(|s| s.parse().ok())
.filter(|&n| (1..=MAX_SECS).contains(&n))
.unwrap_or(DEFAULT_SECS)
})
*SECS.get_or_init(|| parse_tool_timeout_secs(std::env::var(ENV_VAR).ok().as_deref()))
}
/// Seconds — used for logging and matching frontend timeouts.
@@ -27,3 +37,49 @@ pub fn tool_execution_timeout_secs() -> u64 {
pub fn tool_execution_timeout_duration() -> Duration {
Duration::from_secs(resolved_secs())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_when_env_missing() {
assert_eq!(parse_tool_timeout_secs(None), DEFAULT_SECS);
}
#[test]
fn default_when_value_not_numeric() {
assert_eq!(parse_tool_timeout_secs(Some("not-a-number")), DEFAULT_SECS);
assert_eq!(parse_tool_timeout_secs(Some("")), DEFAULT_SECS);
assert_eq!(parse_tool_timeout_secs(Some("12x")), DEFAULT_SECS);
}
#[test]
fn default_when_value_zero() {
// 0 seconds would disable the timeout — reject and fall back.
assert_eq!(parse_tool_timeout_secs(Some("0")), DEFAULT_SECS);
}
#[test]
fn default_when_value_above_max() {
assert_eq!(parse_tool_timeout_secs(Some("3601")), DEFAULT_SECS);
assert_eq!(parse_tool_timeout_secs(Some("99999999999")), DEFAULT_SECS);
}
#[test]
fn default_when_value_negative_or_signed() {
// Negative values fail u64 parse and fall back to default.
assert_eq!(parse_tool_timeout_secs(Some("-5")), DEFAULT_SECS);
}
#[test]
fn accepts_valid_values_at_boundaries() {
assert_eq!(parse_tool_timeout_secs(Some("1")), 1);
assert_eq!(parse_tool_timeout_secs(Some("3600")), MAX_SECS);
}
#[test]
fn accepts_valid_midrange_value() {
assert_eq!(parse_tool_timeout_secs(Some("300")), 300);
}
}