chore: remove verified dead code (#745)

Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
This commit is contained in:
tlongwell-block
2026-05-25 11:12:02 -04:00
committed by GitHub
parent 86f4c5d9a9
commit 90192d8d48
15 changed files with 70 additions and 1645 deletions

View File

@@ -220,27 +220,6 @@ pub struct PromptContext {
// ── AgentPool impl ────────────────────────────────────────────────────────────
impl AgentPool {
/// Create a new pool from a list of initialized agents.
///
/// Agents are placed into indexed slots. The unbounded channel is created
/// here; tasks send results back through `result_tx`.
///
/// Prefer [`AgentPool::from_slots`] for startup paths where some agents may
/// have failed — `new()` packs agents densely and will break the
/// `agent.index` invariant if any slot was skipped.
#[allow(dead_code)]
pub fn new(agents: Vec<OwnedAgent>) -> Self {
let (result_tx, result_rx) = mpsc::unbounded_channel();
let slots = agents.into_iter().map(Some).collect();
Self {
agents: slots,
result_tx,
result_rx,
join_set: JoinSet::new(),
task_map: HashMap::new(),
}
}
/// Create a pool from pre-indexed slots (may contain None for failed startups).
///
/// Slot positions are preserved so that `agent.index` always matches the

View File

@@ -265,7 +265,7 @@ impl EventQueue {
})
.collect();
// Remove the queue entry if now empty (keeps pending_channels() accurate).
// Remove the queue entry if now empty.
if self.queues.get(&channel_id).is_some_and(|q| q.is_empty()) {
self.queues.remove(&channel_id);
}
@@ -490,20 +490,7 @@ impl EventQueue {
.any(|id| !self.in_flight_channels.contains(id))
}
/// Whether any prompt is currently in flight.
#[allow(dead_code)]
pub fn is_in_flight(&self) -> bool {
!self.in_flight_channels.is_empty()
}
/// Total number of pending events across all channels.
#[allow(dead_code)]
pub fn pending_count(&self) -> usize {
self.queues.values().map(|q| q.len()).sum()
}
/// Number of channels with pending events.
#[allow(dead_code)]
pub fn pending_channels(&self) -> usize {
self.queues.len()
}
@@ -517,9 +504,6 @@ impl EventQueue {
///
/// Also clears any `retry_after` throttle for the channel.
///
/// Returns the number of events dropped.
/// Drop all queued (non-in-flight) events for a channel.
///
/// Returns the event IDs of dropped events so the caller can clean up
/// any reactions (👀) that were added at queue-push time.
pub fn drain_channel(&mut self, channel_id: Uuid) -> Vec<String> {
@@ -540,7 +524,6 @@ impl EventQueue {
}
/// Whether a prompt is currently in-flight for the given channel.
#[allow(dead_code)]
pub fn is_channel_in_flight(&self, channel_id: Uuid) -> bool {
self.in_flight_channels.contains(&channel_id)
}
@@ -1108,6 +1091,14 @@ mod tests {
}
}
fn pending_count(q: &EventQueue) -> usize {
q.queues.values().map(|q| q.len()).sum()
}
fn any_in_flight(q: &EventQueue) -> bool {
!q.in_flight_channels.is_empty()
}
// ── Test 1: push + flush_next basic ──────────────────────────────────────
#[test]
@@ -1123,8 +1114,8 @@ mod tests {
assert_eq!(batch.events[0].event.content, "hello");
// Queue should be empty now.
assert_eq!(q.pending_count(), 0);
assert_eq!(q.pending_channels(), 0);
assert_eq!(pending_count(&q), 0);
assert_eq!(q.queues.len(), 0);
}
// ── Test 2: same channel cannot be flushed twice ─────────────────────────
@@ -1136,7 +1127,7 @@ mod tests {
q.push(make_queued(ch, "first"));
let _batch = q.flush_next().expect("first flush should succeed");
assert!(q.is_in_flight());
assert!(any_in_flight(&q));
// Push another event while in-flight.
q.push(make_queued(ch, "second"));
@@ -1162,7 +1153,7 @@ mod tests {
// Complete the in-flight prompt.
q.mark_complete(ch);
assert!(!q.is_in_flight());
assert!(!any_in_flight(&q));
// Now flush should succeed.
let batch = q.flush_next().expect("should flush after mark_complete");
@@ -1182,7 +1173,7 @@ mod tests {
q.push(make_queued(ch, "msg2"));
q.push(make_queued(ch, "msg3"));
assert_eq!(q.pending_count(), 3);
assert_eq!(pending_count(&q), 3);
let batch = q.flush_next().expect("should return batch");
assert_eq!(batch.channel_id, ch);
@@ -1192,8 +1183,8 @@ mod tests {
assert_eq!(batch.events[2].event.content, "msg3");
// All drained.
assert_eq!(q.pending_count(), 0);
assert_eq!(q.pending_channels(), 0);
assert_eq!(pending_count(&q), 0);
assert_eq!(q.queues.len(), 0);
}
// ── Test 5: FIFO fairness ─────────────────────────────────────────────────
@@ -1229,11 +1220,11 @@ mod tests {
// First flush picks A.
let batch_a = q.flush_next().expect("first flush");
assert_eq!(batch_a.channel_id, ch_a);
assert!(q.is_in_flight());
assert!(any_in_flight(&q));
// B still pending.
assert_eq!(q.pending_count(), 1);
assert_eq!(q.pending_channels(), 1);
assert_eq!(pending_count(&q), 1);
assert_eq!(q.queues.len(), 1);
q.mark_complete(ch_a);
@@ -1242,7 +1233,7 @@ mod tests {
assert_eq!(batch_b.channel_id, ch_b);
assert_eq!(batch_b.events[0].event.content, "B-event");
assert_eq!(q.pending_count(), 0);
assert_eq!(pending_count(&q), 0);
}
// ── Test 7: empty queue returns None ─────────────────────────────────────
@@ -1253,37 +1244,6 @@ mod tests {
assert!(q.flush_next().is_none());
}
// ── Test 8: pending_count ─────────────────────────────────────────────────
#[test]
fn test_pending_count() {
let mut q = EventQueue::new(DedupMode::Queue);
let ch_a = Uuid::new_v4();
let ch_b = Uuid::new_v4();
assert_eq!(q.pending_count(), 0);
assert_eq!(q.pending_channels(), 0);
q.push(make_queued(ch_a, "a1"));
q.push(make_queued(ch_a, "a2"));
q.push(make_queued(ch_b, "b1"));
assert_eq!(q.pending_count(), 3);
assert_eq!(q.pending_channels(), 2);
// Flush A (2 events drained).
let _ = q.flush_next();
assert_eq!(q.pending_count(), 1);
assert_eq!(q.pending_channels(), 1);
q.mark_complete(ch_a);
// Flush B (1 event drained).
let _ = q.flush_next();
assert_eq!(q.pending_count(), 0);
assert_eq!(q.pending_channels(), 0);
}
// ── Test 9: format_prompt single event ───────────────────────────────────
#[test]
@@ -1331,7 +1291,7 @@ mod tests {
let batch = queue.flush_next().unwrap();
assert_eq!(batch.events.len(), 2);
assert!(queue.is_in_flight());
assert!(any_in_flight(&queue));
// Simulate failure — requeue the batch.
queue.requeue(batch);
@@ -1491,11 +1451,11 @@ mod tests {
q.push(make_queued(ch, "first"));
let _batch = q.flush_next().expect("first flush");
assert!(q.is_in_flight());
assert!(any_in_flight(&q));
// In drop mode, pushing to the in-flight channel should be discarded.
q.push(make_queued(ch, "dropped"));
assert_eq!(q.pending_count(), 0, "event should be dropped");
assert_eq!(pending_count(&q), 0, "event should be dropped");
q.mark_complete(ch);
// Nothing to flush.
@@ -1512,11 +1472,11 @@ mod tests {
q.push(make_queued(ch_a, "A-first"));
let _batch = q.flush_next().expect("flush A");
assert!(q.is_in_flight());
assert!(any_in_flight(&q));
// Events for ch_b should still queue.
q.push(make_queued(ch_b, "B-event"));
assert_eq!(q.pending_count(), 1);
assert_eq!(pending_count(&q), 1);
q.mark_complete(ch_a);
let batch_b = q.flush_next().expect("flush B");
@@ -1537,7 +1497,7 @@ mod tests {
// Flush A — now A is in-flight.
let batch_a = q.flush_next().expect("flush A");
assert_eq!(batch_a.channel_id, ch_a);
assert!(q.is_in_flight());
assert!(any_in_flight(&q));
// Flush B — B should also be flushable (different channel).
let batch_b = q.flush_next().expect("flush B while A in-flight");
@@ -1548,11 +1508,11 @@ mod tests {
// Complete A only.
q.mark_complete(ch_a);
assert!(q.is_in_flight()); // B still in-flight.
assert!(any_in_flight(&q)); // B still in-flight.
// Complete B.
q.mark_complete(ch_b);
assert!(!q.is_in_flight());
assert!(!any_in_flight(&q));
}
// ── Test 15: same channel cannot be flushed twice ─────────────────────────
@@ -1597,7 +1557,7 @@ mod tests {
// Drop mode: pushing to either in-flight channel is dropped.
q.push(make_queued(ch_a, "A-dropped"));
q.push(make_queued(ch_b, "B-dropped"));
assert_eq!(q.pending_count(), 0);
assert_eq!(pending_count(&q), 0);
q.mark_complete(ch_a);
q.mark_complete(ch_b);
@@ -1660,10 +1620,10 @@ mod tests {
assert!(!q.in_flight_channels.contains(&ch_a));
// B still in-flight.
assert!(q.is_in_flight());
assert!(any_in_flight(&q));
q.mark_complete(ch_b);
assert!(!q.is_in_flight());
assert!(!any_in_flight(&q));
}
// ── Test 19: requeue_preserve_timestamps preserves received_at ───────────
@@ -1722,19 +1682,19 @@ mod tests {
for i in 0..MAX_PENDING_PER_CHANNEL {
q.push(make_queued(ch, &format!("fill-{i}")));
}
assert_eq!(q.pending_count(), MAX_PENDING_PER_CHANNEL);
assert_eq!(pending_count(&q), MAX_PENDING_PER_CHANNEL);
// Flush a batch (removes some events from the queue).
let batch = q.flush_next().expect("should flush");
let batch_size = batch.events.len();
let remaining = MAX_PENDING_PER_CHANNEL - batch_size;
assert_eq!(q.pending_count(), remaining);
assert_eq!(pending_count(&q), remaining);
// Push more events while the batch is "in-flight" — fill back to cap.
for i in 0..batch_size {
q.push(make_queued(ch, &format!("new-{i}")));
}
assert_eq!(q.pending_count(), MAX_PENDING_PER_CHANNEL);
assert_eq!(pending_count(&q), MAX_PENDING_PER_CHANNEL);
// Requeue the original batch — without cap enforcement this would
// push the queue to MAX_PENDING_PER_CHANNEL + batch_size.
@@ -1742,9 +1702,9 @@ mod tests {
// Cap must be enforced: queue should not exceed MAX_PENDING_PER_CHANNEL.
assert!(
q.pending_count() <= MAX_PENDING_PER_CHANNEL,
pending_count(&q) <= MAX_PENDING_PER_CHANNEL,
"queue exceeded cap: {} > {}",
q.pending_count(),
pending_count(&q),
MAX_PENDING_PER_CHANNEL,
);
}
@@ -2358,11 +2318,11 @@ mod tests {
q.push(make_queued(ch, "msg1"));
q.push(make_queued(ch, "msg2"));
assert_eq!(q.pending_count(), 2);
assert_eq!(pending_count(&q), 2);
let drained = q.drain_channel(ch);
assert_eq!(drained.len(), 2);
assert_eq!(q.pending_count(), 0);
assert_eq!(pending_count(&q), 0);
}
#[test]
@@ -2376,7 +2336,7 @@ mod tests {
let drained = q.drain_channel(ch_a);
assert_eq!(drained.len(), 1);
assert_eq!(q.pending_count(), 1); // ch_b still has 1
assert_eq!(pending_count(&q), 1); // ch_b still has 1
}
#[test]
@@ -2393,7 +2353,7 @@ mod tests {
assert!(!q.has_flushable_work());
let drained = q.drain_channel(ch);
assert_eq!(drained.len(), 1);
assert_eq!(q.pending_count(), 0);
assert_eq!(pending_count(&q), 0);
}
#[test]
@@ -2410,7 +2370,7 @@ mod tests {
q.push(make_queued(ch, "msg1"));
let _batch = q.flush_next().unwrap(); // now in-flight
assert!(q.is_in_flight());
assert!(any_in_flight(&q));
// Push another event while in-flight.
q.push(make_queued(ch, "msg2"));
@@ -2418,7 +2378,7 @@ mod tests {
// drain_channel should only remove the queued event, not the in-flight one.
let drained = q.drain_channel(ch);
assert_eq!(drained.len(), 1);
assert!(q.is_in_flight()); // in-flight unaffected
assert!(any_in_flight(&q)); // in-flight unaffected
}
// ── compact_expired_state ─────────────────────────────────────────────

View File

@@ -372,7 +372,6 @@ enum RelayCommand {
filter: ChannelFilter,
},
/// Unsubscribe from a channel (sends a NIP-01 CLOSE).
#[allow(dead_code)]
Unsubscribe { channel_id: Uuid },
/// Reconnect to the relay (re-authenticate and resubscribe).
Reconnect,
@@ -416,11 +415,7 @@ pub struct HarnessRelay {
relay_url: String,
/// Keys used for NIP-42 signing and NIP-98 HTTP auth.
keys: Keys,
/// Agent public key (hex) used as the `#p` filter on subscriptions.
#[allow(dead_code)]
agent_pubkey_hex: String,
/// Optional NIP-OA auth tag for relay membership delegation.
#[allow(dead_code)]
auth_tag: Option<nostr::Tag>,
/// Handle to the background task (for clean shutdown).
/// Wrapped in `Option` so `shutdown()` can take ownership without conflicting
@@ -500,7 +495,6 @@ impl HarnessRelay {
.map_err(|e| RelayError::Http(format!("failed to build HTTP client: {e}")))?,
relay_url: relay_url.to_string(),
keys: keys.clone(),
agent_pubkey_hex: agent_pubkey_hex.to_string(),
auth_tag,
bg_handle: Some(bg_handle),
})
@@ -680,7 +674,6 @@ impl HarnessRelay {
}
/// Unsubscribe from a channel.
#[allow(dead_code)]
pub async fn unsubscribe_channel(&mut self, channel_id: Uuid) -> Result<(), RelayError> {
self.cmd_tx
.send(RelayCommand::Unsubscribe { channel_id })

View File

@@ -132,14 +132,6 @@ pub const KIND_IA_UNARCHIVED: u32 = 8003;
/// NIP-IA: Archived identities list snapshot (relay-signed, replaceable).
pub const KIND_IA_ARCHIVED_LIST: u32 = 13535;
// System / admin (9100–9999)
/// V1 used kind:9001 — moved here due to NIP-29 conflict.
pub const KIND_SYSTEM_TIMER_FIRED: u32 = 9100;
/// V1 used kind:9010 — moved here for NIP-29 range safety.
pub const KIND_SYSTEM_SLASH_COMMAND: u32 = 9110;
/// Internal system flag event for admin tooling.
pub const KIND_SYSTEM_FLAG: u32 = 9900;
// NIP-29 group state (addressable range 39000–39003)
/// NIP-29: Addressable group metadata state.
pub const KIND_NIP29_GROUP_METADATA: u32 = 39000;
@@ -199,9 +191,7 @@ pub const KIND_CANVAS: u32 = 40100;
/// System message for channel state changes (join, leave, rename, etc.).
pub const KIND_SYSTEM_MESSAGE: u32 = 40099;
// Relay-only enrichment kinds (never client-submitted)
/// Thread summaries, reaction rollups (relay-signed sidecar).
pub const KIND_ENRICHMENT: u32 = 40900;
// Relay-only sidecar kinds (never client-submitted)
/// Channel metadata with computed fields (relay-signed sidecar).
pub const KIND_CHANNEL_SUMMARY: u32 = 40901;
/// Bulk presence state (relay-signed sidecar).
@@ -216,18 +206,6 @@ pub const KIND_DM_ADD_MEMBER: u32 = 41011;
pub const KIND_DM_HIDE: u32 = 41012;
/// A new direct-message conversation was created.
pub const KIND_DM_CREATED: u32 = 41001;
/// A member was added to a DM conversation.
pub const KIND_DM_MEMBER_ADDED: u32 = 41002;
/// A member was removed from a DM conversation.
pub const KIND_DM_MEMBER_REMOVED: u32 = 41003;
// Channel / topic management (42000–42999)
/// A new channel topic was created.
pub const KIND_TOPIC_CREATED: u32 = 42001;
/// An existing channel topic was updated.
pub const KIND_TOPIC_UPDATED: u32 = 42002;
/// A channel topic was archived.
pub const KIND_TOPIC_ARCHIVED: u32 = 42003;
// Agent job protocol (43000–43999)
// Not using NIP-90 kinds (5000–6999) — Sprout requires auth chains (depth ≤ 3, breadth ≤ 10).
@@ -244,16 +222,6 @@ pub const KIND_JOB_CANCEL: u32 = 43005;
/// An agent job failed with an error.
pub const KIND_JOB_ERROR: u32 = 43006;
// Subscription system (44000–44999)
/// A new event subscription was created.
pub const KIND_SUBSCRIPTION_CREATED: u32 = 44001;
/// An event matched an active subscription.
pub const KIND_SUBSCRIPTION_MATCHED: u32 = 44002;
/// A subscription was paused.
pub const KIND_SUBSCRIPTION_PAUSED: u32 = 44003;
/// A paused subscription was resumed.
pub const KIND_SUBSCRIPTION_RESUMED: u32 = 44004;
/// Relay-signed notification: the target pubkey was added to a channel.
/// Stored globally (channel_id = None) with p-tag = target, h-tag = channel UUID.
pub const KIND_MEMBER_ADDED_NOTIFICATION: u32 = 44100;
@@ -300,24 +268,10 @@ pub const KIND_WORKFLOW_APPROVAL_GRANTED: u32 = 46011;
pub const KIND_WORKFLOW_APPROVAL_DENIED: u32 = 46012;
// User groups (47000–47999)
/// A new user group was created.
pub const KIND_USER_GROUP_CREATED: u32 = 47001;
/// An existing user group was updated.
pub const KIND_USER_GROUP_UPDATED: u32 = 47002;
/// A user group was deleted.
pub const KIND_USER_GROUP_DELETED: u32 = 47003;
// System / admin custom range (48000–48999)
/// An audit log entry was recorded.
pub const KIND_AUDIT_ENTRY: u32 = 48001;
/// A compliance export was initiated.
pub const KIND_COMPLIANCE_EXPORT: u32 = 48002;
/// A knowledge crystal was created.
pub const KIND_KNOWLEDGE_CRYSTAL_CREATED: u32 = 48003;
/// A knowledge crystal was approved.
pub const KIND_KNOWLEDGE_CRYSTAL_APPROVED: u32 = 48004;
/// A knowledge crystal was updated.
pub const KIND_KNOWLEDGE_CRYSTAL_UPDATED: u32 = 48005;
/// A huddle (audio/video session) was started.
pub const KIND_HUDDLE_STARTED: u32 = 48100;
/// A participant joined a huddle.
@@ -326,10 +280,6 @@ pub const KIND_HUDDLE_PARTICIPANT_JOINED: u32 = 48101;
pub const KIND_HUDDLE_PARTICIPANT_LEFT: u32 = 48102;
/// A huddle ended.
pub const KIND_HUDDLE_ENDED: u32 = 48103;
/// A media track was published in a huddle.
pub const KIND_HUDDLE_TRACK_PUBLISHED: u32 = 48104;
/// A huddle recording became available.
pub const KIND_HUDDLE_RECORDING_AVAILABLE: u32 = 48105;
/// Huddle channel guidelines/rules document.
pub const KIND_HUDDLE_GUIDELINES: u32 = 48106;
@@ -398,9 +348,6 @@ pub const ALL_KINDS: &[u32] = &[
KIND_IA_ARCHIVED,
KIND_IA_UNARCHIVED,
KIND_IA_ARCHIVED_LIST,
KIND_SYSTEM_TIMER_FIRED,
KIND_SYSTEM_SLASH_COMMAND,
KIND_SYSTEM_FLAG,
KIND_NIP29_GROUP_METADATA,
KIND_NIP29_GROUP_ADMINS,
KIND_NIP29_GROUP_MEMBERS,
@@ -421,28 +368,18 @@ pub const ALL_KINDS: &[u32] = &[
KIND_STREAM_MESSAGE_DIFF,
KIND_CANVAS,
KIND_SYSTEM_MESSAGE,
KIND_ENRICHMENT,
KIND_CHANNEL_SUMMARY,
KIND_PRESENCE_SNAPSHOT,
KIND_DM_OPEN,
KIND_DM_ADD_MEMBER,
KIND_DM_HIDE,
KIND_DM_CREATED,
KIND_DM_MEMBER_ADDED,
KIND_DM_MEMBER_REMOVED,
KIND_TOPIC_CREATED,
KIND_TOPIC_UPDATED,
KIND_TOPIC_ARCHIVED,
KIND_JOB_REQUEST,
KIND_JOB_ACCEPTED,
KIND_JOB_PROGRESS,
KIND_JOB_RESULT,
KIND_JOB_CANCEL,
KIND_JOB_ERROR,
KIND_SUBSCRIPTION_CREATED,
KIND_SUBSCRIPTION_MATCHED,
KIND_SUBSCRIPTION_PAUSED,
KIND_SUBSCRIPTION_RESUMED,
KIND_MEMBER_ADDED_NOTIFICATION,
KIND_MEMBER_REMOVED_NOTIFICATION,
KIND_WORKFLOW_DEF,
@@ -465,20 +402,11 @@ pub const ALL_KINDS: &[u32] = &[
KIND_WORKFLOW_APPROVAL_REQUESTED,
KIND_WORKFLOW_APPROVAL_GRANTED,
KIND_WORKFLOW_APPROVAL_DENIED,
KIND_USER_GROUP_CREATED,
KIND_USER_GROUP_UPDATED,
KIND_USER_GROUP_DELETED,
KIND_AUDIT_ENTRY,
KIND_COMPLIANCE_EXPORT,
KIND_KNOWLEDGE_CRYSTAL_CREATED,
KIND_KNOWLEDGE_CRYSTAL_APPROVED,
KIND_KNOWLEDGE_CRYSTAL_UPDATED,
KIND_HUDDLE_STARTED,
KIND_HUDDLE_PARTICIPANT_JOINED,
KIND_HUDDLE_PARTICIPANT_LEFT,
KIND_HUDDLE_ENDED,
KIND_HUDDLE_TRACK_PUBLISHED,
KIND_HUDDLE_RECORDING_AVAILABLE,
KIND_HUDDLE_GUIDELINES,
KIND_MEDIA_UPLOAD,
KIND_GIT_REPO_ANNOUNCEMENT,
@@ -549,13 +477,10 @@ pub const fn is_command_kind(kind: u32) -> bool {
)
}
/// Returns `true` if `kind` is a relay-only enrichment kind (40900–40902).
/// Returns `true` if `kind` is a relay-only sidecar kind.
/// Client submission of these kinds must be rejected.
pub const fn is_relay_only_kind(kind: u32) -> bool {
matches!(
kind,
KIND_ENRICHMENT | KIND_CHANNEL_SUMMARY | KIND_PRESENCE_SNAPSHOT
)
matches!(kind, KIND_CHANNEL_SUMMARY | KIND_PRESENCE_SNAPSHOT)
}
/// Extract the kind from a nostr Event as u32.
@@ -587,7 +512,7 @@ const _: () = assert!(
// Compile-time: all Sprout kind constants fit in nostr's u16-backed Kind.
const _: () = assert!(KIND_AUTH <= u16::MAX as u32);
const _: () = assert!(KIND_CANVAS <= u16::MAX as u32);
const _: () = assert!(KIND_HUDDLE_RECORDING_AVAILABLE <= u16::MAX as u32);
const _: () = assert!(KIND_HUDDLE_GUIDELINES <= u16::MAX as u32);
const _: () = assert!(EPHEMERAL_KIND_MIN < EPHEMERAL_KIND_MAX);
#[cfg(test)]

View File

@@ -1,759 +0,0 @@
//! Legacy JSON persona format adapter.
//!
//! Reads the flat `.persona.json` format used by the Sprout desktop app
//! and converts to `.persona.md` (YAML frontmatter + markdown body).
//!
//! This is a read-only compatibility shim — the JSON format is deprecated.
//! The ACP harness only consumes `.persona.md`; the desktop converts at
//! deploy time via this adapter.
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::persona::PersonaConfig;
use crate::validate::ValidationDiagnostic;
// ── Errors ────────────────────────────────────────────────────────────────────
#[derive(Debug, thiserror::Error)]
pub enum LegacyError {
#[error("failed to read file: {0}")]
Io(#[from] std::io::Error),
#[error("failed to parse JSON: {0}")]
Json(#[from] serde_json::Error),
#[error("migration error: {0}")]
Migration(String),
}
// ── Legacy persona record (desktop JSON format) ──────────────────────────────
/// The flat JSON persona format stored by the Sprout desktop app.
///
/// Source: `desktop/src-tauri/src/managed_agents/types.rs` → `PersonaRecord`.
/// This struct mirrors that layout for deserialization — we don't depend on
/// the desktop crate directly.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LegacyPersonaRecord {
pub id: String,
pub display_name: String,
#[serde(default)]
pub avatar_url: Option<String>,
pub system_prompt: String,
#[serde(default)]
pub provider: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub name_pool: Vec<String>,
#[serde(default)]
pub is_builtin: bool,
#[serde(default = "default_true")]
pub is_active: bool,
#[serde(default)]
pub created_at: String,
#[serde(default)]
pub updated_at: String,
}
fn default_true() -> bool {
true
}
// ── Loading ──────────────────────────────────────────────────────────────────
/// Load legacy persona records from a JSON file.
///
/// Accepts either a single `LegacyPersonaRecord` object or an array of them
/// (the desktop stores an array in `personas.json`).
pub fn load_legacy_json(path: &Path) -> Result<Vec<LegacyPersonaRecord>, LegacyError> {
const MAX_LEGACY_FILE_SIZE: u64 = 10 * 1024 * 1024; // 10MB
let metadata = std::fs::metadata(path)?;
if metadata.len() > MAX_LEGACY_FILE_SIZE {
return Err(LegacyError::Migration(format!(
"file too large: {} bytes (max {})",
metadata.len(),
MAX_LEGACY_FILE_SIZE
)));
}
let content = std::fs::read_to_string(path)?;
let trimmed = content.trim();
if trimmed.starts_with('[') {
Ok(serde_json::from_str::<Vec<LegacyPersonaRecord>>(trimmed)?)
} else if trimmed.starts_with('{') {
Ok(vec![serde_json::from_str::<LegacyPersonaRecord>(trimmed)?])
} else {
Err(LegacyError::Migration(
"file is not a JSON object or array".into(),
))
}
}
// ── Field mapping ────────────────────────────────────────────────────────────
/// Derive the V7 `name` field from the legacy `id`.
///
/// Strips the `builtin:` prefix if present, lowercases, and replaces
/// spaces/special chars with hyphens.
pub fn derive_name(id: &str) -> String {
let stripped = id
.strip_prefix("builtin:")
.or_else(|| id.strip_prefix("custom:"))
.unwrap_or(id);
stripped
.to_lowercase()
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'-'
}
})
.collect::<String>()
.trim_matches('-')
.to_string()
}
/// Merge `provider` and `model` into the V7 `"provider:model-id"` format.
///
/// Cases:
/// 1. Both present → `"provider:model-id"`
/// 2. Only model → `"model-id"` (no colon)
/// 3. Only provider → `None` (provider alone is not useful)
/// 4. Neither → `None`
pub fn merge_provider_model(provider: Option<&str>, model: Option<&str>) -> Option<String> {
match (provider, model) {
(Some(p), Some(m)) if !p.is_empty() && !m.is_empty() => Some(format!("{p}:{m}")),
(_, Some(m)) if !m.is_empty() => Some(m.to_string()),
_ => None,
}
}
/// Extract a one-line description from the system prompt.
///
/// Takes the first non-empty line, truncated to at most 120 bytes of UTF-8.
/// Uses char boundaries to avoid slicing through multibyte codepoints.
/// Falls back to "Migrated persona" if the prompt is empty.
pub fn derive_description(system_prompt: &str) -> String {
system_prompt
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.map(|line| {
if line.len() > 119 {
// Find the last char boundary ending at or before byte 119,
// so the result (content + '…') is at most 120 chars.
let truncate_at = line
.char_indices()
.take_while(|(i, _)| *i < 119)
.last()
.map(|(i, c)| i + c.len_utf8())
.unwrap_or(119);
format!("{}…", &line[..truncate_at])
} else {
line.to_string()
}
})
.unwrap_or_else(|| "Migrated persona".to_string())
}
// ── Migration: JSON → .persona.md ────────────────────────────────────────────
/// Convert a single legacy record to `.persona.md` content (YAML frontmatter
/// + markdown body).
///
/// Returns `(filename, content)` where filename is `{name}.persona.md`.
pub fn legacy_to_persona_md(record: &LegacyPersonaRecord) -> Result<(String, String), LegacyError> {
let name = derive_name(&record.id);
if name.is_empty() {
return Err(LegacyError::Migration(format!(
"could not derive a valid name from id {:?}",
record.id
)));
}
if record.display_name.trim().is_empty() {
return Err(LegacyError::Migration("display_name is empty".into()));
}
let description = derive_description(&record.system_prompt);
let model = merge_provider_model(record.provider.as_deref(), record.model.as_deref());
// Build YAML frontmatter using serde_yaml for correct escaping.
// BTreeMap keeps keys in alphabetical order for deterministic output.
let mut fm = serde_json::Map::new();
fm.insert("name".into(), serde_json::Value::String(name.clone()));
fm.insert(
"display_name".into(),
serde_json::Value::String(record.display_name.clone()),
);
fm.insert("description".into(), serde_json::Value::String(description));
if let Some(ref avatar) = record.avatar_url {
// Skip data: URIs — they're inline base64, not pack-relative paths.
if !avatar.starts_with("data:") && !avatar.is_empty() {
fm.insert("avatar".into(), serde_json::Value::String(avatar.clone()));
}
}
if let Some(ref m) = model {
fm.insert("model".into(), serde_json::Value::String(m.clone()));
}
let yaml_value = serde_json::Value::Object(fm);
let frontmatter = serde_yaml::to_string(&yaml_value)
.map_err(|e| LegacyError::Migration(format!("failed to serialize frontmatter: {e}")))?;
// serde_yaml emits a trailing newline; trim it since we add our own.
let frontmatter = frontmatter.trim_end();
let body = &record.system_prompt;
let content = format!("---\n{frontmatter}\n---\n\n{body}");
let filename = format!("{name}.persona.md");
Ok((filename, content))
}
/// Migrate a legacy JSON file to a directory of `.persona.md` files.
///
/// Creates the output directory if it doesn't exist. Skips inactive and
/// built-in personas by default (controlled by `include_builtin` and
/// `include_inactive`).
pub fn migrate_json_to_md(
input_path: &Path,
output_dir: &Path,
include_builtin: bool,
include_inactive: bool,
) -> Result<MigrationReport, LegacyError> {
let records = load_legacy_json(input_path)?;
std::fs::create_dir_all(output_dir)?;
let mut report = MigrationReport::default();
for record in &records {
if !include_builtin && record.is_builtin {
report.skipped_builtin += 1;
continue;
}
if !include_inactive && !record.is_active {
report.skipped_inactive += 1;
continue;
}
match legacy_to_persona_md(record) {
Ok((filename, content)) => {
let out_path = output_dir.join(&filename);
if out_path.exists() {
report
.diagnostics
.push(ValidationDiagnostic::Warning(format!(
"output file already exists, skipping: {}",
out_path.display()
)));
continue;
}
std::fs::write(&out_path, &content)?;
report.migrated.push(filename);
}
Err(e) => {
report.diagnostics.push(ValidationDiagnostic::Error(format!(
"failed to migrate {:?}: {e}",
record.id
)));
}
}
}
Ok(report)
}
/// Summary of a migration run.
#[derive(Debug, Default)]
pub struct MigrationReport {
pub migrated: Vec<String>,
pub skipped_builtin: usize,
pub skipped_inactive: usize,
pub diagnostics: Vec<ValidationDiagnostic>,
}
impl std::fmt::Display for MigrationReport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(f, "Migration complete:")?;
writeln!(f, " Migrated: {}", self.migrated.len())?;
for name in &self.migrated {
writeln!(f, " ✓ {name}")?;
}
if self.skipped_builtin > 0 {
writeln!(f, " Skipped (built-in): {}", self.skipped_builtin)?;
}
if self.skipped_inactive > 0 {
writeln!(f, " Skipped (inactive): {}", self.skipped_inactive)?;
}
for w in &self.diagnostics {
writeln!(f, " {w}")?;
}
Ok(())
}
}
// ── Adapter: LegacyPersonaRecord → PersonaConfig ─────────────────────────────
/// Convert a legacy JSON persona record into a typed `PersonaConfig`.
///
/// This is the bridge between the old flat JSON format and the new V7 struct.
/// Fields that don't exist in the legacy format (`skills`, `mcp_servers`,
/// `subscribe`, `respond_to`, `hooks`, etc.) get their default/empty values.
pub fn legacy_to_persona_config(
record: &LegacyPersonaRecord,
) -> Result<PersonaConfig, LegacyError> {
let name = derive_name(&record.id);
if name.is_empty() {
return Err(LegacyError::Migration(format!(
"could not derive a valid name from id {:?}",
record.id
)));
}
if record.display_name.trim().is_empty() {
return Err(LegacyError::Migration("display_name is empty".into()));
}
Ok(PersonaConfig {
name,
display_name: record.display_name.clone(),
avatar: record
.avatar_url
.as_ref()
.filter(|a| !a.starts_with("data:") && !a.is_empty())
.cloned(),
description: derive_description(&record.system_prompt),
version: None,
author: None,
skills: Vec::new(),
mcp_servers: Vec::new(),
subscribe: None,
triggers: None,
model: merge_provider_model(record.provider.as_deref(), record.model.as_deref()),
temperature: None,
max_context_tokens: None,
thread_replies: None,
broadcast_replies: None,
hooks: None,
prompt: record.system_prompt.clone(),
})
}
// ── Tests ────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
// ── derive_name ──────────────────────────────────────────────────────
#[test]
fn derive_name_strips_builtin_prefix() {
assert_eq!(derive_name("builtin:solo"), "solo");
}
#[test]
fn derive_name_strips_custom_prefix() {
assert_eq!(derive_name("custom:my-agent"), "my-agent");
}
#[test]
fn derive_name_lowercases() {
assert_eq!(derive_name("MyAgent"), "myagent");
}
#[test]
fn derive_name_replaces_spaces() {
assert_eq!(derive_name("My Cool Agent"), "my-cool-agent");
}
#[test]
fn derive_name_plain_id() {
assert_eq!(derive_name("lep"), "lep");
}
// ── merge_provider_model ─────────────────────────────────────────────
#[test]
fn merge_both_present() {
assert_eq!(
merge_provider_model(Some("anthropic"), Some("claude-sonnet-4-20250514")),
Some("anthropic:claude-sonnet-4-20250514".into())
);
}
#[test]
fn merge_model_only() {
assert_eq!(
merge_provider_model(None, Some("gpt-4o")),
Some("gpt-4o".into())
);
}
#[test]
fn merge_provider_only() {
assert_eq!(merge_provider_model(Some("anthropic"), None), None);
}
#[test]
fn merge_neither() {
assert_eq!(merge_provider_model(None, None), None);
}
#[test]
fn merge_empty_strings() {
assert_eq!(merge_provider_model(Some(""), Some("")), None);
}
#[test]
fn merge_empty_provider_with_model() {
assert_eq!(
merge_provider_model(Some(""), Some("gpt-4o")),
Some("gpt-4o".into())
);
}
// ── derive_description ───────────────────────────────────────────────
#[test]
fn description_from_first_line() {
assert_eq!(
derive_description("You are a security bot.\nMore details here."),
"You are a security bot."
);
}
#[test]
fn description_skips_empty_lines() {
assert_eq!(
derive_description("\n\n \nActual content here."),
"Actual content here."
);
}
#[test]
fn description_empty_prompt() {
assert_eq!(derive_description(""), "Migrated persona");
assert_eq!(derive_description(" \n \n "), "Migrated persona");
}
#[test]
fn description_truncates_long_line() {
let long = "x".repeat(200);
let desc = derive_description(&long);
assert!(
desc.chars().count() <= 120,
"should be at most 120 chars (119 + ellipsis)"
);
assert!(desc.ends_with('…'));
}
#[test]
fn description_truncates_multibyte_utf8() {
// 200 emoji chars — each is 4 bytes. Old code would panic on byte slice.
let long: String = "🔒".repeat(200);
let desc = derive_description(&long);
assert!(desc.chars().count() <= 120);
assert!(desc.ends_with('…'));
// Verify it's valid UTF-8 (would panic on construction if not).
assert!(!desc.is_empty());
}
#[test]
fn description_truncates_multibyte_safely() {
// 40 CJK characters = 120 bytes (3 bytes each), then more
let long = "你".repeat(50); // 150 bytes
let desc = derive_description(&long);
assert!(!desc.is_empty());
// Should not panic, and should end with …
assert!(desc.ends_with('…'));
}
// ── legacy_to_persona_md ─────────────────────────────────────────────
#[test]
fn basic_conversion() {
let record = LegacyPersonaRecord {
id: "custom:lep".into(),
display_name: "Lep 🍀".into(),
avatar_url: Some("./avatars/lep.png".into()),
system_prompt: "You are Lep, a security reviewer.".into(),
provider: Some("anthropic".into()),
model: Some("claude-sonnet-4-20250514".into()),
name_pool: vec!["clover".into()],
is_builtin: false,
is_active: true,
created_at: "2026-01-01T00:00:00Z".into(),
updated_at: "2026-01-01T00:00:00Z".into(),
};
let (filename, content) = legacy_to_persona_md(&record).unwrap();
assert_eq!(filename, "lep.persona.md");
assert!(content.starts_with("---\n"), "should start with ---");
// serde_yaml may or may not quote simple strings; check key: value pairs
assert!(
content.contains("name:") && content.contains("lep"),
"should contain name: lep"
);
assert!(
content.contains("display_name:"),
"should contain display_name"
);
assert!(
content.contains("model:") && content.contains("anthropic:claude-sonnet-4-20250514"),
"should contain model"
);
assert!(
content.contains("avatar:") && content.contains("./avatars/lep.png"),
"should contain avatar"
);
assert!(
content.contains("You are Lep, a security reviewer."),
"should contain system prompt"
);
// name_pool should NOT appear
assert!(
!content.contains("name_pool"),
"name_pool should not appear"
);
assert!(
!content.contains("clover"),
"name_pool values should not appear"
);
}
#[test]
fn conversion_skips_data_uri_avatar() {
let record = LegacyPersonaRecord {
id: "builtin:solo".into(),
display_name: "Solo".into(),
avatar_url: Some("data:image/png;base64,iVBOR...".into()),
system_prompt: "You are Solo.".into(),
provider: None,
model: None,
name_pool: vec![],
is_builtin: true,
is_active: true,
created_at: String::new(),
updated_at: String::new(),
};
let (_, content) = legacy_to_persona_md(&record).unwrap();
assert!(!content.contains("avatar:"));
assert!(!content.contains("data:image"));
}
#[test]
fn conversion_no_model() {
let record = LegacyPersonaRecord {
id: "custom:plain".into(),
display_name: "Plain".into(),
avatar_url: None,
system_prompt: "A simple agent.".into(),
provider: None,
model: None,
name_pool: vec![],
is_builtin: false,
is_active: true,
created_at: String::new(),
updated_at: String::new(),
};
let (_, content) = legacy_to_persona_md(&record).unwrap();
assert!(!content.contains("model:"));
}
#[test]
fn conversion_preserves_prompt_whitespace() {
// Fix #4: migration must not trim leading/trailing whitespace from prompts.
let prompt = "\n You are Lep.\n\nBe helpful.\n ";
let record = LegacyPersonaRecord {
id: "custom:lep".into(),
display_name: "Lep".into(),
avatar_url: None,
system_prompt: prompt.into(),
provider: None,
model: None,
name_pool: vec![],
is_builtin: false,
is_active: true,
created_at: String::new(),
updated_at: String::new(),
};
let (_, content) = legacy_to_persona_md(&record).unwrap();
// The body section (after the closing ---\n\n) must contain the prompt verbatim.
let body_start = content.find("---\n\n").unwrap() + 5;
let body = &content[body_start..];
assert_eq!(body, prompt, "prompt must be written as-is, not trimmed");
}
// ── load_legacy_json ─────────────────────────────────────────────────
#[test]
fn load_single_object() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("single.json");
std::fs::write(
&path,
r#"{"id":"custom:test","display_name":"Test","system_prompt":"Hello","created_at":"","updated_at":""}"#,
)
.unwrap();
let records = load_legacy_json(&path).unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0].id, "custom:test");
}
#[test]
fn load_array() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("array.json");
std::fs::write(
&path,
r#"[{"id":"a","display_name":"A","system_prompt":"","created_at":"","updated_at":""},{"id":"b","display_name":"B","system_prompt":"","created_at":"","updated_at":""}]"#,
)
.unwrap();
let records = load_legacy_json(&path).unwrap();
assert_eq!(records.len(), 2);
}
// ── legacy_to_persona_config ─────────────────────────────────────────
fn make_record(id: &str, prompt: &str) -> LegacyPersonaRecord {
LegacyPersonaRecord {
id: id.into(),
display_name: "Test Agent".into(),
avatar_url: Some("./avatars/test.png".into()),
system_prompt: prompt.into(),
provider: Some("anthropic".into()),
model: Some("claude-sonnet-4-20250514".into()),
name_pool: vec!["alpha".into()],
is_builtin: false,
is_active: true,
created_at: String::new(),
updated_at: String::new(),
}
}
#[test]
fn adapter_basic() {
let record = make_record("custom:lep", "You are Lep.");
let config = legacy_to_persona_config(&record).unwrap();
assert_eq!(config.name, "lep");
assert_eq!(config.display_name, "Test Agent");
assert_eq!(config.avatar.as_deref(), Some("./avatars/test.png"));
assert_eq!(
config.model.as_deref(),
Some("anthropic:claude-sonnet-4-20250514")
);
assert_eq!(config.prompt, "You are Lep.");
assert!(config.skills.is_empty());
assert!(config.mcp_servers.is_empty());
assert!(config.subscribe.is_none());
assert!(config.triggers.is_none());
assert!(config.hooks.is_none());
}
#[test]
fn adapter_skips_data_uri_avatar() {
let mut record = make_record("builtin:solo", "Solo prompt.");
record.avatar_url = Some("data:image/png;base64,abc".into());
let config = legacy_to_persona_config(&record).unwrap();
assert!(config.avatar.is_none());
}
#[test]
fn adapter_no_model() {
let mut record = make_record("custom:plain", "Plain.");
record.provider = None;
record.model = None;
let config = legacy_to_persona_config(&record).unwrap();
assert!(config.model.is_none());
}
#[test]
fn adapter_preserves_prompt_whitespace() {
// Fix #4: legacy_to_persona_config must not trim the system prompt.
let prompt = "\n You are Lep.\n\nBe helpful.\n ";
let record = make_record("custom:lep", prompt);
let config = legacy_to_persona_config(&record).unwrap();
assert_eq!(
config.prompt, prompt,
"prompt must be stored as-is, not trimmed"
);
}
// ── empty display_name validation ────────────────────────────────────
#[test]
fn empty_display_name_is_rejected_by_md() {
let mut record = make_record("custom:lep", "You are Lep.");
record.display_name = "".into();
let err = legacy_to_persona_md(&record).unwrap_err();
assert!(
matches!(err, LegacyError::Migration(ref msg) if msg.contains("display_name")),
"expected Migration error mentioning display_name, got: {err}"
);
}
#[test]
fn whitespace_only_display_name_is_rejected_by_md() {
let mut record = make_record("custom:lep", "You are Lep.");
record.display_name = " ".into();
let err = legacy_to_persona_md(&record).unwrap_err();
assert!(matches!(err, LegacyError::Migration(_)));
}
#[test]
fn empty_display_name_is_rejected_by_config() {
let mut record = make_record("custom:lep", "You are Lep.");
record.display_name = "".into();
let err = legacy_to_persona_config(&record).unwrap_err();
assert!(
matches!(err, LegacyError::Migration(ref msg) if msg.contains("display_name")),
"expected Migration error mentioning display_name, got: {err}"
);
}
#[test]
fn whitespace_only_display_name_is_rejected_by_config() {
let mut record = make_record("custom:lep", "You are Lep.");
record.display_name = " ".into();
let err = legacy_to_persona_config(&record).unwrap_err();
assert!(matches!(err, LegacyError::Migration(_)));
}
// ── Round-trip: JSON → .persona.md → parse back ──────────────────────
#[test]
fn round_trip_json_to_md_to_config() {
let record = make_record(
"custom:roundtrip",
"You are a round-trip test agent.\n\nBe helpful.",
);
let (filename, md_content) = legacy_to_persona_md(&record).unwrap();
assert_eq!(filename, "roundtrip.persona.md");
// Parse the generated .persona.md back using the real parser.
let parsed = crate::persona::parse_persona_md(&md_content).unwrap();
assert_eq!(parsed.name, "roundtrip");
assert_eq!(parsed.display_name, "Test Agent");
assert_eq!(
parsed.model.as_deref(),
Some("anthropic:claude-sonnet-4-20250514")
);
assert_eq!(parsed.avatar.as_deref(), Some("./avatars/test.png"));
// The prompt should survive the round-trip.
assert!(parsed.prompt.contains("You are a round-trip test agent."));
assert!(parsed.prompt.contains("Be helpful."));
}
}

View File

@@ -1,4 +1,3 @@
pub mod legacy;
pub mod manifest;
pub mod merge;
pub mod pack;

View File

@@ -7,7 +7,6 @@
use std::fs;
use std::path::Path;
use sprout_persona::legacy;
use sprout_persona::pack;
use sprout_persona::persona;
use sprout_persona::resolve;
@@ -267,115 +266,6 @@ You are a test agent. Be precise and thorough.
assert_eq!(rt.all_messages, Some(false));
}
// ── Legacy JSON → PersonaConfig adapter ──────────────────────────────────────
#[test]
fn legacy_adapter_full_pipeline() {
let record = legacy::LegacyPersonaRecord {
id: "custom:security-bot".into(),
display_name: "Security Bot 🔒".into(),
avatar_url: Some("./avatars/security.png".into()),
system_prompt: "You are a security-focused assistant.\n\nAnalyze code for vulnerabilities."
.into(),
provider: Some("anthropic".into()),
model: Some("claude-sonnet-4-20250514".into()),
name_pool: vec!["sentinel".into(), "guardian".into()],
is_builtin: false,
is_active: true,
created_at: "2026-01-01T00:00:00Z".into(),
updated_at: "2026-04-01T00:00:00Z".into(),
};
// 1. Convert to PersonaConfig.
let config = legacy::legacy_to_persona_config(&record).unwrap();
assert_eq!(config.name, "security-bot");
assert_eq!(config.display_name, "Security Bot 🔒");
assert_eq!(
config.model.as_deref(),
Some("anthropic:claude-sonnet-4-20250514")
);
assert!(config.prompt.contains("security-focused assistant"));
// name_pool should NOT survive migration.
// (No field for it in PersonaConfig — this is correct.)
// 2. Generate .persona.md from the legacy record.
let (filename, md_content) = legacy::legacy_to_persona_md(&record).unwrap();
assert_eq!(filename, "security-bot.persona.md");
// 3. Parse the generated .persona.md back.
let parsed = persona::parse_persona_md(&md_content).unwrap();
assert_eq!(parsed.name, "security-bot");
assert_eq!(parsed.display_name, "Security Bot 🔒");
assert_eq!(
parsed.model.as_deref(),
Some("anthropic:claude-sonnet-4-20250514")
);
assert!(parsed.prompt.contains("security-focused assistant"));
assert!(parsed.prompt.contains("Analyze code for vulnerabilities"));
}
// ── Legacy migration: batch JSON → pack directory ────────────────────────────
#[test]
fn legacy_batch_migration() {
let dir = tempfile::tempdir().unwrap();
let input_path = dir.path().join("personas.json");
let output_dir = dir.path().join("agents");
fs::write(
&input_path,
r#"[
{
"id": "custom:alpha",
"display_name": "Alpha",
"system_prompt": "You are Alpha.",
"is_builtin": false,
"is_active": true,
"created_at": "",
"updated_at": ""
},
{
"id": "builtin:solo",
"display_name": "Solo",
"system_prompt": "You are Solo.",
"is_builtin": true,
"is_active": true,
"created_at": "",
"updated_at": ""
},
{
"id": "custom:inactive",
"display_name": "Inactive",
"system_prompt": "You are Inactive.",
"is_builtin": false,
"is_active": false,
"created_at": "",
"updated_at": ""
}
]"#,
)
.unwrap();
// Migrate with defaults (skip builtin, skip inactive).
let report = legacy::migrate_json_to_md(&input_path, &output_dir, false, false).unwrap();
assert_eq!(
report.migrated.len(),
1,
"only active non-builtin should migrate"
);
assert_eq!(report.migrated[0], "alpha.persona.md");
assert_eq!(report.skipped_builtin, 1);
assert_eq!(report.skipped_inactive, 1);
// Verify the migrated file parses.
let content = fs::read_to_string(output_dir.join("alpha.persona.md")).unwrap();
let parsed = persona::parse_persona_md(&content).unwrap();
assert_eq!(parsed.name, "alpha");
assert_eq!(parsed.display_name, "Alpha");
assert!(parsed.prompt.contains("You are Alpha"));
}
// ── Validation catches real errors ───────────────────────────────────────────
#[test]
@@ -700,53 +590,6 @@ fn resolve_persona_by_name_not_found() {
);
}
/// Legacy JSON → PersonaConfig → .persona.md → resolve round-trip.
#[test]
fn resolve_legacy_migration_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
// 1. Start with a legacy JSON record
let record = legacy::LegacyPersonaRecord {
id: "custom:security-bot".into(),
display_name: "Security Bot 🔒".into(),
avatar_url: Some("./avatars/security.png".into()),
system_prompt: "You are a security-focused assistant.".into(),
provider: Some("anthropic".into()),
model: Some("claude-sonnet-4-20250514".into()),
name_pool: vec![],
is_builtin: false,
is_active: true,
created_at: String::new(),
updated_at: String::new(),
};
// 2. Convert to .persona.md
let (_filename, md_content) = legacy::legacy_to_persona_md(&record).unwrap();
// 3. Build a pack directory with the migrated persona
fs::create_dir_all(root.join(".plugin")).unwrap();
fs::create_dir_all(root.join("agents")).unwrap();
fs::write(
root.join(".plugin/plugin.json"),
r#"{"id":"com.test.migrated","name":"Migrated","version":"1.0.0","personas":["agents/security-bot.persona.md"]}"#,
)
.unwrap();
fs::write(root.join("agents/security-bot.persona.md"), &md_content).unwrap();
// 4. Resolve the pack
let resolved = resolve::resolve_pack(root).unwrap();
assert_eq!(resolved.personas.len(), 1);
let persona = &resolved.personas[0];
assert_eq!(persona.name, "security-bot");
assert_eq!(persona.display_name, "Security Bot 🔒");
assert_eq!(persona.provider.as_deref(), Some("anthropic"));
assert_eq!(persona.model.as_deref(), Some("claude-sonnet-4-20250514"));
assert!(persona.system_prompt.contains("security-focused assistant"));
}
// ── Validation: zero-persona and duplicate-name in full pipeline ─────────────
/// Validation catches zero-persona packs in the full pipeline.

View File

@@ -744,32 +744,6 @@ impl Translator {
f
}
/// Translate a Sprout REQ filter to NIP-28 format (for outbound subscription
/// forwarding, e.g. when the proxy subscribes on behalf of a client).
///
/// - kind:9 / kind:40002 → kind:42
pub fn translate_filter_outbound(&self, filter: &Filter) -> Filter {
let mut f = filter.clone();
if let Some(ref kinds) = filter.kinds {
let new_kinds: Vec<Kind> = kinds
.iter()
.map(|k| {
let k_u32 = k.as_u16() as u32;
let standard_k = self.kind_translator.to_standard(k_u32);
// SAFETY: standard kind values (42, 41) always fit in u16
Kind::Custom(
u16::try_from(standard_k)
.expect("SAFETY: standard kind values always fit in u16"),
)
})
.collect();
f = f.remove_kinds(kinds.iter().cloned()).kinds(new_kinds);
}
f
}
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
@@ -1053,24 +1027,6 @@ mod tests {
assert!(has_h_filter, "filter must have #h tag constraints injected");
}
#[test]
fn filter_outbound_translates_kind() {
let (translator, _) = make_translator();
let filter = Filter::new().kind(Kind::Custom(KIND_STREAM_MESSAGE as u16));
let translated = translator.translate_filter_outbound(&filter);
let kinds = translated.kinds.as_ref().expect("filter must have kinds");
assert!(
kinds.contains(&Kind::Custom(42)),
"filter must contain kind:42 after outbound translation"
);
assert!(
!kinds.contains(&Kind::Custom(KIND_STREAM_MESSAGE as u16)),
"filter must not contain KIND_STREAM_MESSAGE after outbound translation"
);
}
// ── Test 6b: Filter — #e channel ref translates to #h UUID (FIX A) ─────
#[test]

View File

@@ -155,30 +155,6 @@ impl UpstreamClient {
self.inner.connected.try_read().map(|v| *v).unwrap_or(false)
}
// ── Subscription tracking helpers ─────────────────────────────────────────
/// Track a subscription by storing its REQ JSON for replay on reconnect.
/// Called by the server layer when forwarding REQs from downstream clients.
#[allow(dead_code)] // Used in tests; kept for future server-layer integration
pub(crate) fn track_subscription(&self, sub_id: &str, req_json: &str) {
self.inner
.active_subs
.insert(sub_id.to_string(), req_json.to_string());
}
/// Remove a subscription from the active set.
/// Called by the server layer when handling CLOSEs from downstream clients.
#[allow(dead_code)] // Used in tests; kept for future server-layer integration
pub(crate) fn untrack_subscription(&self, sub_id: &str) {
self.inner.active_subs.remove(sub_id);
}
/// Returns the number of currently tracked active subscriptions.
#[allow(dead_code)] // Used in tests; kept for future server-layer integration
pub(crate) fn active_subscription_count(&self) -> usize {
self.inner.active_subs.len()
}
// ── Run loop ──────────────────────────────────────────────────────────────
/// Run the upstream connection loop. Reconnects on disconnect with exponential
@@ -592,36 +568,19 @@ mod tests {
});
}
#[test]
fn track_and_untrack_subscriptions() {
let client = UpstreamClient::new("ws://localhost:3000", "sprout_test");
assert_eq!(client.active_subscription_count(), 0);
client.track_subscription("sub-1", r#"["REQ","sub-1",{}]"#);
client.track_subscription("sub-2", r#"["REQ","sub-2",{}]"#);
assert_eq!(client.active_subscription_count(), 2);
client.untrack_subscription("sub-1");
assert_eq!(client.active_subscription_count(), 1);
client.untrack_subscription("sub-2");
assert_eq!(client.active_subscription_count(), 0);
}
#[test]
fn send_req_tracks_subscription() {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let client = UpstreamClient::new("ws://localhost:3000", "sprout_test");
assert_eq!(client.active_subscription_count(), 0);
assert_eq!(client.inner.active_subs.len(), 0);
let sub_id = SubscriptionId::new("tracked-sub");
let filters = vec![Filter::new().kind(Kind::TextNote)];
client.send_req(sub_id.clone(), filters).await.unwrap();
assert_eq!(client.active_subscription_count(), 1);
assert_eq!(client.inner.active_subs.len(), 1);
assert!(
client.inner.active_subs.contains_key("tracked-sub"),
"subscription should be tracked"
@@ -629,7 +588,7 @@ mod tests {
// CLOSE should remove it.
client.send_close(sub_id).await.unwrap();
assert_eq!(client.active_subscription_count(), 0);
assert_eq!(client.inner.active_subs.len(), 0);
});
}
}

View File

@@ -32,8 +32,6 @@ pub mod rate_limiter;
/// Redis SUBSCRIBE for channel event delivery.
pub mod subscriber;
/// Typing indicator tracking in Redis.
pub mod typing;
pub use error::PubSubError;
use std::collections::HashMap;
@@ -132,20 +130,6 @@ impl PubSubManager {
) -> Result<HashMap<String, String>, PubSubError> {
presence::get_presence_bulk(&self.pool, pubkeys).await
}
/// Records that `pubkey` is currently typing in `channel_id`. Expires after 5 seconds.
pub async fn set_typing(
&self,
channel_id: Uuid,
pubkey: &PublicKey,
) -> Result<(), PubSubError> {
typing::set_typing(&self.pool, channel_id, pubkey).await
}
/// Returns hex pubkeys of users who have typed in `channel_id` within the last 5 seconds.
pub async fn get_typing(&self, channel_id: Uuid) -> Result<Vec<String>, PubSubError> {
typing::get_typing(&self.pool, channel_id).await
}
}
#[cfg(test)]
@@ -235,47 +219,4 @@ mod tests {
let status = presence::get_presence(&pool, &pubkey).await.unwrap();
assert!(status.is_none());
}
#[tokio::test]
#[ignore = "requires Redis"]
async fn test_typing_set_and_prune() {
let pool = make_test_pool();
let channel_id = Uuid::new_v4();
let pk1 = Keys::generate().public_key();
let pk2 = Keys::generate().public_key();
typing::set_typing(&pool, channel_id, &pk1).await.unwrap();
typing::set_typing(&pool, channel_id, &pk2).await.unwrap();
let active = typing::get_typing(&pool, channel_id).await.unwrap();
assert!(active.contains(&pk1.to_hex()));
assert!(active.contains(&pk2.to_hex()));
let stale_pk = Keys::generate().public_key();
{
let mut conn = pool.get().await.unwrap();
let key = typing::typing_key(channel_id);
let stale_score = chrono::Utc::now().timestamp() as f64 - 10.0;
redis::cmd("ZADD")
.arg(&key)
.arg(stale_score)
.arg(stale_pk.to_hex())
.query_async::<()>(&mut conn)
.await
.unwrap();
}
typing::set_typing(&pool, channel_id, &pk1).await.unwrap();
let active = typing::get_typing(&pool, channel_id).await.unwrap();
assert!(!active.contains(&stale_pk.to_hex()));
assert!(active.contains(&pk1.to_hex()));
let mut conn = pool.get().await.unwrap();
redis::cmd("DEL")
.arg(typing::typing_key(channel_id))
.query_async::<()>(&mut conn)
.await
.unwrap();
}
}

View File

@@ -1,153 +0,0 @@
//! Typing indicators — Redis sorted set with 5-second active window.
//!
//! Each `set_typing` call ZADDs the member, prunes entries older than 5s,
//! and refreshes a key-level TTL of `TYPING_KEY_TTL_SECS` seconds on the
//! sorted set. This prevents orphaned keys from accumulating in Redis when a
//! channel goes quiet: individual members expire via `ZREMRANGEBYSCORE`, but
//! without a key-level TTL the empty sorted set would persist indefinitely.
use deadpool_redis::Pool;
use nostr::PublicKey;
use uuid::Uuid;
use crate::error::PubSubError;
/// Active typing window in seconds. Members with a score older than this are pruned.
pub const TYPING_WINDOW_SECS: f64 = 5.0;
/// Key-level TTL for the typing sorted set. If no `set_typing` call is made
/// for this duration, Redis automatically deletes the key, preventing orphaned
/// empty sets from accumulating when a channel goes permanently quiet.
///
/// Must be longer than `TYPING_WINDOW_SECS` so that a key is never expired
/// while it still contains live members.
pub const TYPING_KEY_TTL_SECS: u64 = 60;
/// Returns the Redis key for the typing sorted set of `channel_id`.
pub fn typing_key(channel_id: Uuid) -> String {
format!("sprout:typing:{}", channel_id)
}
/// Records that `pubkey` is typing in `channel_id` and prunes stale entries.
pub async fn set_typing(
pool: &Pool,
channel_id: Uuid,
pubkey: &PublicKey,
) -> Result<(), PubSubError> {
let mut conn = pool.get().await?;
let key = typing_key(channel_id);
let now = chrono::Utc::now().timestamp() as f64;
redis::cmd("ZADD")
.arg(&key)
.arg(now)
.arg(pubkey.to_hex())
.query_async::<()>(&mut conn)
.await?;
redis::cmd("ZREMRANGEBYSCORE")
.arg(&key)
.arg("-inf")
.arg(now - TYPING_WINDOW_SECS)
.query_async::<()>(&mut conn)
.await?;
// Refresh key-level TTL so that orphaned empty sets are eventually
// reclaimed by Redis even if no further writes arrive for this channel.
redis::cmd("EXPIRE")
.arg(&key)
.arg(TYPING_KEY_TTL_SECS)
.query_async::<()>(&mut conn)
.await?;
Ok(())
}
/// Returns hex pubkeys of users who typed in `channel_id` within the last [`TYPING_WINDOW_SECS`].
pub async fn get_typing(pool: &Pool, channel_id: Uuid) -> Result<Vec<String>, PubSubError> {
let mut conn = pool.get().await?;
let key = typing_key(channel_id);
let now = chrono::Utc::now().timestamp() as f64;
let members: Vec<String> = redis::cmd("ZRANGEBYSCORE")
.arg(&key)
.arg(now - TYPING_WINDOW_SECS)
.arg("+inf")
.query_async(&mut conn)
.await?;
Ok(members)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_util::make_test_pool;
use nostr::Keys;
#[test]
fn test_typing_key_format() {
let channel_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
assert_eq!(
typing_key(channel_id),
"sprout:typing:550e8400-e29b-41d4-a716-446655440000"
);
}
#[tokio::test]
#[ignore = "requires Redis"]
async fn test_typing_set_and_prune() {
let pool = make_test_pool();
let channel_id = Uuid::new_v4();
let pk1 = Keys::generate().public_key();
let pk2 = Keys::generate().public_key();
set_typing(&pool, channel_id, &pk1).await.unwrap();
set_typing(&pool, channel_id, &pk2).await.unwrap();
let typing = get_typing(&pool, channel_id).await.unwrap();
assert_eq!(typing.len(), 2);
assert!(typing.contains(&pk1.to_hex()));
assert!(typing.contains(&pk2.to_hex()));
// Insert a stale entry (score = now - 10s)
let stale_pk = Keys::generate().public_key();
{
let mut conn = pool.get().await.unwrap();
let key = typing_key(channel_id);
let stale_score = chrono::Utc::now().timestamp() as f64 - 10.0;
redis::cmd("ZADD")
.arg(&key)
.arg(stale_score)
.arg(stale_pk.to_hex())
.query_async::<()>(&mut conn)
.await
.unwrap();
}
// Prune fires on next set_typing
set_typing(&pool, channel_id, &pk1).await.unwrap();
let typing = get_typing(&pool, channel_id).await.unwrap();
assert!(!typing.contains(&stale_pk.to_hex()));
assert!(typing.contains(&pk1.to_hex()));
// pk1 + pk2 should remain (both within 5s window)
assert!(!typing.is_empty() && typing.len() <= 2);
let mut conn = pool.get().await.unwrap();
redis::cmd("DEL")
.arg(typing_key(channel_id))
.query_async::<()>(&mut conn)
.await
.unwrap();
}
#[tokio::test]
#[ignore = "requires Redis"]
async fn test_typing_empty_channel() {
let pool = make_test_pool();
let channel_id = Uuid::new_v4();
let typing = get_typing(&pool, channel_id).await.unwrap();
assert!(typing.is_empty());
}
}

View File

@@ -30,18 +30,17 @@ fn reject(reason: &'static str) {
fn bounded_kind_label(kind: u32) -> String {
match kind {
0..=9 | 1059 | 1063 => kind.to_string(),
9000..=9022 | 9030..=9032 | 9100 | 9110 | 9900 => kind.to_string(),
8000..=8003 | 9000..=9022 | 9030..=9036 => kind.to_string(),
13534..=13535 => kind.to_string(),
20000..=29999 => kind.to_string(),
30023 | 30315 | 39000..=39003 => kind.to_string(),
40002..=40100 => kind.to_string(),
41001..=41003 => kind.to_string(),
42001..=42003 => kind.to_string(),
41001 | 41010..=41012 => kind.to_string(),
43001..=43006 => kind.to_string(),
44001..=44004 | 44100..=44101 => kind.to_string(),
44100..=44101 => kind.to_string(),
45001..=45003 => kind.to_string(),
46001..=46012 => kind.to_string(),
47001..=47003 => kind.to_string(),
48001..=48005 | 48100..=48105 => kind.to_string(),
46001..=46012 | 46020 | 46030..=46031 => kind.to_string(),
48001 | 48100..=48103 | 48106 => kind.to_string(),
49001 => kind.to_string(),
_ => "other".to_string(),
}

View File

@@ -1,5 +1,7 @@
//! imeta tag validation helpers — shared between ingest pipeline and bridge.
use sprout_media::validation::mime_to_ext;
/// Validate imeta tags for correctness and safety.
///
/// Shared between REST (send_message) and WebSocket (handle_event) paths.
@@ -153,7 +155,7 @@ pub fn validate_imeta_tags(tags: &[Vec<String>], media_base_url: &str) -> Result
}
}
if let Some(ext_in_url) = extract_ext_from_media_url(&url_value) {
let expected_ext = mime_to_canonical_ext(&m_value);
let expected_ext = mime_to_ext(&m_value);
if ext_in_url != expected_ext {
return Err("imeta url extension does not match m".into());
}
@@ -319,18 +321,6 @@ fn extract_ext_from_media_url(url: &str) -> Option<&str> {
}
}
/// Map MIME to canonical extension (must match sprout-media's mime_to_ext).
fn mime_to_canonical_ext(mime: &str) -> &str {
match mime {
"image/jpeg" => "jpg",
"image/png" => "png",
"image/gif" => "gif",
"image/webp" => "webp",
"video/mp4" => "mp4",
_ => "bin",
}
}
/// Validate that a URL references a valid local media blob path.
fn is_local_media_url(url: &str, media_base_url: &str) -> bool {
const ALLOWED_EXTS: &[&str] = &["jpg", "png", "gif", "webp", "mp4"];

View File

@@ -20,18 +20,18 @@ use sprout_core::kind::{
KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_REPO_STATE,
KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN,
KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, KIND_HUDDLE_PARTICIPANT_JOINED,
KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_RECORDING_AVAILABLE, KIND_HUDDLE_STARTED,
KIND_HUDDLE_TRACK_PUBLISHED, KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST,
KIND_LONG_FORM, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION,
KIND_MUTE_LIST, KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP,
KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST,
KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST,
KIND_NIP65_RELAY_LIST_METADATA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE, KIND_PROFILE,
KIND_REACTION, KIND_READ_STATE, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED,
KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED,
KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEXT_NOTE,
KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER,
RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER,
KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_STARTED, KIND_IA_ARCHIVE_REQUEST,
KIND_IA_UNARCHIVE_REQUEST, KIND_LONG_FORM, KIND_MEMBER_ADDED_NOTIFICATION,
KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MUTE_LIST, KIND_NIP29_CREATE_GROUP,
KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA,
KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER,
KIND_NIP43_LEAVE_REQUEST, KIND_NIP65_RELAY_LIST_METADATA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE,
KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, KIND_STREAM_MESSAGE,
KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT,
KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2,
KIND_STREAM_REMINDER, KIND_TEXT_NOTE, KIND_USER_STATUS, KIND_WORKFLOW_DEF,
KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE,
RELAY_ADMIN_REMOVE_MEMBER,
};
use sprout_core::verification::verify_event;
@@ -218,8 +218,6 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result<Scope, &'static s
| KIND_HUDDLE_PARTICIPANT_JOINED
| KIND_HUDDLE_PARTICIPANT_LEFT
| KIND_HUDDLE_ENDED
| KIND_HUDDLE_TRACK_PUBLISHED
| KIND_HUDDLE_RECORDING_AVAILABLE
| KIND_HUDDLE_GUIDELINES => Ok(Scope::ChannelsWrite),
// NIP-34: Git repository events
KIND_GIT_REPO_ANNOUNCEMENT | KIND_GIT_REPO_STATE => Ok(Scope::ReposWrite),

View File

@@ -622,116 +622,6 @@ pub fn build_contact_list(
Ok(EventBuilder::new(Kind::Custom(3), "").tags(tags))
}
// ── Huddle shared helper ──────────────────────────────────────────────────────
/// Shared builder for huddle lifecycle events (kinds 48100–48103).
///
/// All huddle events share: an `["h", parent_channel_id]` tag, JSON content
/// with `ephemeral_channel_id`, optional extra content fields, and optional
/// p-tags for participant identity.
fn build_huddle_event_sdk(
kind: u16,
parent_channel_id: Uuid,
ephemeral_channel_id: Uuid,
extra_fields: &[(&str, &str)],
participant_pubkey: Option<&str>,
) -> Result<EventBuilder, SdkError> {
let mut tags = vec![tag(&["h", &parent_channel_id.to_string()])?];
if let Some(pk) = participant_pubkey {
tags.push(tag(&["p", pk])?);
}
let mut map = serde_json::Map::new();
map.insert(
"ephemeral_channel_id".into(),
serde_json::Value::String(ephemeral_channel_id.to_string()),
);
for (k, v) in extra_fields {
map.insert((*k).into(), serde_json::Value::String(v.to_string()));
}
let content = serde_json::Value::Object(map).to_string();
Ok(EventBuilder::new(Kind::Custom(kind), content).tags(tags))
}
// ── Builder 26: build_huddle_started ─────────────────────────────────────────
/// Build a huddle-started event (kind 48100).
///
/// Posted to the parent channel as an advisory UI hint that a huddle has begun.
/// - `parent_channel_id`: the channel the huddle belongs to (h-tag)
/// - `ephemeral_channel_id`: the short-lived channel UUID for the huddle session
/// - `livekit_room`: LiveKit room name participants should join
pub fn build_huddle_started(
parent_channel_id: Uuid,
ephemeral_channel_id: Uuid,
livekit_room: &str,
) -> Result<EventBuilder, SdkError> {
build_huddle_event_sdk(
48100,
parent_channel_id,
ephemeral_channel_id,
&[("livekit_room", livekit_room)],
None,
)
}
// ── Builder 27: build_huddle_participant_joined ───────────────────────────────
/// Build a huddle-participant-joined event (kind 48101).
///
/// Posted to the parent channel when a participant enters the huddle.
/// - `parent_channel_id`: the channel the huddle belongs to (h-tag)
/// - `ephemeral_channel_id`: the short-lived channel UUID for the huddle session
/// - `participant_pubkey`: hex pubkey of the joining participant (p-tag)
pub fn build_huddle_participant_joined(
parent_channel_id: Uuid,
ephemeral_channel_id: Uuid,
participant_pubkey: &str,
) -> Result<EventBuilder, SdkError> {
build_huddle_event_sdk(
48101,
parent_channel_id,
ephemeral_channel_id,
&[],
Some(participant_pubkey),
)
}
// ── Builder 28: build_huddle_participant_left ─────────────────────────────────
/// Build a huddle-participant-left event (kind 48102).
///
/// Posted to the parent channel when a participant exits the huddle.
/// - `parent_channel_id`: the channel the huddle belongs to (h-tag)
/// - `ephemeral_channel_id`: the short-lived channel UUID for the huddle session
/// - `participant_pubkey`: hex pubkey of the departing participant (p-tag)
pub fn build_huddle_participant_left(
parent_channel_id: Uuid,
ephemeral_channel_id: Uuid,
participant_pubkey: &str,
) -> Result<EventBuilder, SdkError> {
build_huddle_event_sdk(
48102,
parent_channel_id,
ephemeral_channel_id,
&[],
Some(participant_pubkey),
)
}
// ── Builder 29: build_huddle_ended ───────────────────────────────────────────
/// Build a huddle-ended event (kind 48103).
///
/// Posted to the parent channel when the huddle session concludes.
/// - `parent_channel_id`: the channel the huddle belongs to (h-tag)
/// - `ephemeral_channel_id`: the short-lived channel UUID for the huddle session
pub fn build_huddle_ended(
parent_channel_id: Uuid,
ephemeral_channel_id: Uuid,
) -> Result<EventBuilder, SdkError> {
build_huddle_event_sdk(48103, parent_channel_id, ephemeral_channel_id, &[], None)
}
// ── Helper: extract_channel_id ───────────────────────────────────────────────
/// Extract the channel UUID from an event's `h` tag.
@@ -1899,101 +1789,6 @@ mod tests {
assert!(matches!(err, SdkError::InvalidInput(_)));
}
// ── build_huddle_started ──────────────────────────────────────────────────
#[test]
fn huddle_started_happy_path() {
let parent = uuid();
let ephemeral = uuid();
let ev = sign(build_huddle_started(parent, ephemeral, "my-room").unwrap());
assert_eq!(ev.kind.as_u16(), 48100);
assert!(has_tag(&ev, "h", &parent.to_string()));
let v: serde_json::Value = serde_json::from_str(&ev.content).unwrap();
assert_eq!(v["ephemeral_channel_id"], ephemeral.to_string());
assert_eq!(v["livekit_room"], "my-room");
}
#[test]
fn huddle_started_h_tag_is_parent_not_ephemeral() {
let parent = uuid();
let ephemeral = uuid();
let ev = sign(build_huddle_started(parent, ephemeral, "room").unwrap());
assert!(has_tag(&ev, "h", &parent.to_string()));
assert!(!has_tag(&ev, "h", &ephemeral.to_string()));
}
// ── build_huddle_participant_joined ───────────────────────────────────────
#[test]
fn huddle_participant_joined_happy_path() {
let parent = uuid();
let ephemeral = uuid();
let pubkey = "a".repeat(64);
let ev = sign(build_huddle_participant_joined(parent, ephemeral, &pubkey).unwrap());
assert_eq!(ev.kind.as_u16(), 48101);
assert!(has_tag(&ev, "h", &parent.to_string()));
assert!(has_tag(&ev, "p", &pubkey));
let v: serde_json::Value = serde_json::from_str(&ev.content).unwrap();
assert_eq!(v["ephemeral_channel_id"], ephemeral.to_string());
}
#[test]
fn huddle_participant_joined_h_tag_is_parent_not_ephemeral() {
let parent = uuid();
let ephemeral = uuid();
let pubkey = "b".repeat(64);
let ev = sign(build_huddle_participant_joined(parent, ephemeral, &pubkey).unwrap());
assert!(has_tag(&ev, "h", &parent.to_string()));
assert!(!has_tag(&ev, "h", &ephemeral.to_string()));
}
// ── build_huddle_participant_left ─────────────────────────────────────────
#[test]
fn huddle_participant_left_happy_path() {
let parent = uuid();
let ephemeral = uuid();
let pubkey = "c".repeat(64);
let ev = sign(build_huddle_participant_left(parent, ephemeral, &pubkey).unwrap());
assert_eq!(ev.kind.as_u16(), 48102);
assert!(has_tag(&ev, "h", &parent.to_string()));
assert!(has_tag(&ev, "p", &pubkey));
let v: serde_json::Value = serde_json::from_str(&ev.content).unwrap();
assert_eq!(v["ephemeral_channel_id"], ephemeral.to_string());
}
#[test]
fn huddle_participant_left_h_tag_is_parent_not_ephemeral() {
let parent = uuid();
let ephemeral = uuid();
let pubkey = "d".repeat(64);
let ev = sign(build_huddle_participant_left(parent, ephemeral, &pubkey).unwrap());
assert!(has_tag(&ev, "h", &parent.to_string()));
assert!(!has_tag(&ev, "h", &ephemeral.to_string()));
}
// ── build_huddle_ended ────────────────────────────────────────────────────
#[test]
fn huddle_ended_happy_path() {
let parent = uuid();
let ephemeral = uuid();
let ev = sign(build_huddle_ended(parent, ephemeral).unwrap());
assert_eq!(ev.kind.as_u16(), 48103);
assert!(has_tag(&ev, "h", &parent.to_string()));
let v: serde_json::Value = serde_json::from_str(&ev.content).unwrap();
assert_eq!(v["ephemeral_channel_id"], ephemeral.to_string());
}
#[test]
fn huddle_ended_h_tag_is_parent_not_ephemeral() {
let parent = uuid();
let ephemeral = uuid();
let ev = sign(build_huddle_ended(parent, ephemeral).unwrap());
assert!(has_tag(&ev, "h", &parent.to_string()));
assert!(!has_tag(&ev, "h", &ephemeral.to_string()));
}
// ── build_repo_announcement ───────────────────────────────────────────────
#[test]