Skip to content
Merged
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
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "herdr-context"
version = "0.19.3"
version = "0.19.4"
edition = "2024"
rust-version = "1.97.1"
autobenches = false
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@ test suite.

## Release status

Version `0.19.3` is the current release line. Tag `v0.19.3`, Cargo
Version `0.19.4` is the current release line. Tag `v0.19.4`, Cargo
metadata, both manifests, and the minimum Herdr version are validated
together; pushing a `v*` tag runs formatting, Clippy, the full test suite,
and the contract checks, then publishes generated GitHub release notes.
Expand Down
2 changes: 1 addition & 1 deletion herdr-plugin.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
id = "herdr-context"
name = "herdr-context"
version = "0.19.3"
version = "0.19.4"
min_herdr_version = "0.8.0"
description = "Per-tab project context dock"
platforms = ["linux", "macos"]
Expand Down
47 changes: 30 additions & 17 deletions src/conversations/sources/claude_code.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ use serde::de::IgnoredAny;
use super::known_stores::{
EntryKind, FormatFailure, KnownFormat, KnownJsonlSource, KnownStore, MAX_CANDIDATE_PATHS,
ParseOutcome, ParsedMetadata, PendingMetadata, canonical_cwd, claude_project_directory,
parse_rfc3339, push_inventory_error, push_listing_error, push_shape_error,
validate_tool_version, validate_uuid,
normalize_metadata_title, parse_rfc3339, push_inventory_error, push_listing_error,
push_shape_error, validate_tool_version, validate_uuid,
};
use super::{
ConversationCandidate, ConversationSource, ConversationSourceError, DiscoveryBatch,
Expand Down Expand Up @@ -114,9 +114,9 @@ impl KnownFormat for ClaudeCodeFormat {
}

fn adapter_revision(&self) -> u32 {
// 3: current and future auxiliary metadata plus descendant cwd changes
// are accepted, so cached rejections from revision 2 must be revisited.
3
// 4: Claude titles are now extracted, so cached title-less metadata
// from revision 3 must be revisited.
4
}

fn list_candidates(
Expand Down Expand Up @@ -260,6 +260,9 @@ impl KnownFormat for ClaudeCodeFormat {
validate_uuid(expected_id, 4)?;

let mut session_id = previous.map(|metadata| metadata.session_id.clone());
let mut title = previous
.and_then(|metadata| metadata.title.clone())
.or_else(|| previous_pending.and_then(|metadata| metadata.title.clone()));
let mut created_at = previous
.map(|metadata| metadata.created_at)
.or_else(|| previous_pending.and_then(|metadata| metadata.created_at));
Expand Down Expand Up @@ -460,10 +463,12 @@ impl KnownFormat for ClaudeCodeFormat {
}
"ai-title" => {
validate_claude_session(&record, expected_id)?;
if record.ai_title.is_none() {
return Err(FormatFailure::unsupported(
"Claude ai-title record is missing its title",
));
if title.is_none() {
title = record
.ai_title
.as_ref()
.and_then(serde_json::Value::as_str)
.and_then(normalize_metadata_title);
}
}
"queue-operation" => {
Expand All @@ -484,17 +489,24 @@ impl KnownFormat for ClaudeCodeFormat {
updated_at =
Some(updated_at.map_or(timestamp, |current| current.max(timestamp)));
}
"mode" | "custom-title" | "agent-name" | "agent-setting" => {
"custom-title" => {
validate_claude_session(&record, expected_id)?;
if let Some(native_title) = record
.custom_title
.as_ref()
.and_then(serde_json::Value::as_str)
.and_then(normalize_metadata_title)
{
title = Some(native_title);
}
}
"mode" | "agent-name" | "agent-setting" => {
validate_claude_session(&record, expected_id)?;
let (present, message) = match record.kind.as_str() {
"mode" => (
record.mode.is_some(),
"Claude mode record is missing its mode",
),
"custom-title" => (
record.custom_title.is_some(),
"Claude custom-title record is missing its title",
),
"agent-name" => (
record.agent_name.is_some(),
"Claude agent-name record is missing its name",
Expand Down Expand Up @@ -532,14 +544,15 @@ impl KnownFormat for ClaudeCodeFormat {

let Some(session_id) = session_id else {
return Ok(ParseOutcome::IdentityPending(PendingMetadata {
title,
created_at,
updated_at,
record_count,
}));
};
Ok(ParseOutcome::Metadata(ParsedMetadata {
session_id,
title: None,
title,
created_at: created_at.ok_or_else(|| {
FormatFailure::unsupported("Claude JSONL contains no current timestamp")
})?,
Expand Down Expand Up @@ -584,10 +597,10 @@ struct ClaudeRecord {
#[serde(rename = "permissionMode")]
permission_mode: Option<IgnoredAny>,
#[serde(rename = "aiTitle")]
ai_title: Option<IgnoredAny>,
ai_title: Option<serde_json::Value>,
mode: Option<IgnoredAny>,
#[serde(rename = "customTitle")]
custom_title: Option<IgnoredAny>,
custom_title: Option<serde_json::Value>,
#[serde(rename = "agentName")]
agent_name: Option<IgnoredAny>,
#[serde(rename = "agentSetting")]
Expand Down
22 changes: 20 additions & 2 deletions src/conversations/sources/known_stores.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ const MAX_RECORDS: usize = 32_768;
const MAX_WATERMARK_BYTES: usize = 4 * 1024 * 1024;
const MAX_METADATA_TITLE_BYTES: usize = 256;

const fn valid_metadata_title(value: &str) -> bool {
value.len() <= MAX_METADATA_TITLE_BYTES
}

pub(super) fn normalize_metadata_title(value: &str) -> Option<String> {
let value = value.trim();
(!value.is_empty() && valid_metadata_title(value)).then(|| value.to_owned())
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct KnownStoreRoots {
claude_code: PathBuf,
Expand Down Expand Up @@ -1104,6 +1113,7 @@ pub(super) struct ParsedMetadata {

#[derive(Clone, Debug)]
pub(super) struct PendingMetadata {
pub(super) title: Option<String>,
pub(super) created_at: Option<SystemTime>,
pub(super) updated_at: Option<SystemTime>,
pub(super) record_count: u64,
Expand Down Expand Up @@ -1201,6 +1211,8 @@ impl StoredRejection {

#[derive(Clone, Debug, Deserialize, Serialize)]
struct StoredPendingMetadata {
#[serde(default)]
title: Option<String>,
created_at: Option<(bool, u64, u32)>,
updated_at: Option<(bool, u64, u32)>,
record_count: u64,
Expand All @@ -1209,6 +1221,7 @@ struct StoredPendingMetadata {
impl StoredPendingMetadata {
fn from_pending(metadata: &PendingMetadata) -> Self {
Self {
title: metadata.title.clone(),
created_at: metadata.created_at.map(system_time_parts),
updated_at: metadata.updated_at.map(system_time_parts),
record_count: metadata.record_count,
Expand All @@ -1217,6 +1230,10 @@ impl StoredPendingMetadata {

fn to_pending(&self) -> Option<PendingMetadata> {
if self.record_count == 0
|| self
.title
.as_deref()
.is_some_and(|title| !valid_metadata_title(title))
|| self
.created_at
.is_some_and(|(_, _, nanos)| nanos >= 1_000_000_000)
Expand All @@ -1236,6 +1253,7 @@ impl StoredPendingMetadata {
return None;
}
Some(PendingMetadata {
title: self.title.clone(),
created_at,
updated_at,
record_count: self.record_count,
Expand Down Expand Up @@ -1279,8 +1297,8 @@ impl StoredMetadata {
|| chain_updated_at.2 >= 1_000_000_000
|| self
.title
.as_ref()
.is_some_and(|title| title.len() > MAX_METADATA_TITLE_BYTES)
.as_deref()
.is_some_and(|title| !valid_metadata_title(title))
{
return None;
}
Expand Down
108 changes: 102 additions & 6 deletions tests/conversation_external_discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -830,6 +830,79 @@ fn claude_adapter_revision_revisits_revision_two_watermarks() {
assert!(refreshed.errors().is_empty(), "{:?}", refreshed.errors());
}

#[test]
fn claude_title_revision_revisits_cached_titleless_metadata() {
let (_project_dir, project) = project();
let home = TempDir::new().expect("home");
let session_id = "11111111-1111-4111-8111-111111111111";
let destination = home
.path()
.join(".claude/projects/custom-project-key")
.join(format!("{session_id}.jsonl"));
fs::create_dir_all(destination.parent().expect("fixture parent")).expect("store");
let transcript = format!(
"{}\n{}",
serde_json::json!({
"type": "ai-title",
"aiTitle": "generated cached title",
"sessionId": session_id,
}),
fixture_text(
"claude-code/-workspace-project/11111111-1111-4111-8111-111111111111.jsonl",
&project,
)
);
fs::write(destination, transcript).expect("Claude fixture");
let source = ClaudeCodeSource::new(project.clone(), home.path().join(".claude/projects"))
.expect("Claude source");
let first = source
.discover(
&project,
None,
DiscoveryLimit::new(8).expect("discovery limit"),
)
.expect("initial discovery");
let mut revision_three = serde_json::from_str::<serde_json::Value>(
first.next_watermark().expect("watermark").token(),
)
.expect("watermark JSON");
for entry in revision_three
.as_object_mut()
.expect("watermark map")
.values_mut()
{
let entry = entry.as_object_mut().expect("watermark entry");
entry.insert("adapter_revision".to_owned(), serde_json::json!(3));
entry
.get_mut("summary")
.and_then(serde_json::Value::as_object_mut)
.expect("stored summary")
.insert("title".to_owned(), serde_json::Value::Null);
}
let revision_three = SourceWatermark::new(
source.source_id().clone(),
serde_json::to_string(&revision_three).expect("revision three watermark"),
)
.expect("source watermark");

let refreshed = source
.discover(
&project,
Some(&revision_three),
DiscoveryLimit::new(8).expect("discovery limit"),
)
.expect("refreshed discovery");

assert_eq!(refreshed.candidates().len(), 1);
let conversation = source
.extract_metadata(
&refreshed.candidates()[0],
MetadataBudget::new(512 * 1024).expect("metadata budget"),
)
.expect("refreshed metadata");
assert_eq!(conversation.title(), Some("generated cached title"));
}

#[test]
fn global_store_overrides_resolve_without_home_scans() {
let home = TempDir::new().expect("home");
Expand Down Expand Up @@ -890,7 +963,22 @@ fn claude_accepts_current_auxiliary_record_shapes() {
}),
serde_json::json!({
"type": "custom-title",
"customTitle": "sanitized title",
"customTitle": 42,
"sessionId": session_id,
}),
serde_json::json!({
"type": "ai-title",
"aiTitle": " ",
"sessionId": session_id,
}),
serde_json::json!({
"type": "ai-title",
"aiTitle": "generated fallback title",
"sessionId": session_id,
}),
serde_json::json!({
"type": "custom-title",
"customTitle": "custom conversation title",
"sessionId": session_id,
}),
serde_json::json!({
Expand Down Expand Up @@ -936,7 +1024,7 @@ fn claude_accepts_current_auxiliary_record_shapes() {
}),
serde_json::json!({
"type": "ai-title",
"aiTitle": "sanitized title",
"aiTitle": "generated conversation title",
"sessionId": session_id,
}),
serde_json::json!({
Expand Down Expand Up @@ -966,10 +1054,9 @@ fn claude_accepts_current_auxiliary_record_shapes() {
let source = ClaudeCodeSource::new(project.clone(), home.path().join(".claude/projects"))
.expect("Claude source");

assert_eq!(
discover_one(&source, &project).session_reference().id(),
session_id
);
let conversation = discover_one(&source, &project);
assert_eq!(conversation.session_reference().id(), session_id);
assert_eq!(conversation.title(), Some("custom conversation title"));
}

#[test]
Expand Down Expand Up @@ -1030,6 +1117,14 @@ fn claude_resumes_after_a_page_of_unknown_auxiliary_records() {
"attachment": {"type": "goal_status", "met": false},
})
));
transcript.push_str(&format!(
"{}\n",
serde_json::json!({
"type": "ai-title",
"aiTitle": "generated paginated title",
"sessionId": session_id,
})
));
while transcript.len() <= 512 * 1024 {
transcript.push_str(&auxiliary);
}
Expand Down Expand Up @@ -1071,6 +1166,7 @@ fn claude_resumes_after_a_page_of_unknown_auxiliary_records() {
conversation.created_at(),
Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_767_139_200))
);
assert_eq!(conversation.title(), Some("generated paginated title"));
}

#[test]
Expand Down
6 changes: 3 additions & 3 deletions tests/test_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,15 @@ class ReleaseContractTests(unittest.TestCase):
def test_repository_contract_is_release_ready(self) -> None:
contract = release.validate_repository(ROOT)

self.assertEqual(contract.version, "0.19.3")
self.assertEqual(contract.version, "0.19.4")
self.assertEqual(contract.min_herdr_version, "0.8.0")
self.assertEqual(len(contract.performance_metrics), 12)

def test_trigger_tag_must_exactly_match_cargo_version(self) -> None:
contract = release.validate_repository(ROOT)

release.validate_trigger_tag(contract, "v0.19.3")
with self.assertRaisesRegex(release.ReleaseError, "exactly v0.19.3"):
release.validate_trigger_tag(contract, "v0.19.4")
with self.assertRaisesRegex(release.ReleaseError, "exactly v0.19.4"):
release.validate_trigger_tag(contract, "vtest")

def test_failed_budget_requires_complete_risk_acceptance(self) -> None:
Expand Down
Loading