session_events.rs (52698B)
1 //! Convert parsed JSONL lines into kind-1988 nostr events. 2 //! 3 //! Each JSONL line becomes one or more nostr events. Assistant messages with 4 //! mixed content (text + tool_use blocks) are split into separate events. 5 //! Events are threaded using NIP-10 `e` tags with root/reply markers. 6 7 use crate::session_jsonl::{self, ContentBlock, JsonlLine}; 8 use nostrdb::{NoteBuildOptions, NoteBuilder}; 9 use std::collections::HashMap; 10 11 /// Nostr event kind for AI conversation notes. 12 pub const AI_CONVERSATION_KIND: u32 = 1988; 13 14 /// Nostr event kind for source-data companion events (archive). 15 /// Each 1989 event carries the raw JSONL for one line, linked to the 16 /// corresponding 1988 event via an `e` tag. 17 pub const AI_SOURCE_DATA_KIND: u32 = 1989; 18 19 /// Nostr event kind for AI session state (parameterized replaceable, NIP-33). 20 /// One event per session, auto-replaced by nostrdb on update. 21 /// `d` tag = claude_session_id. 22 pub const AI_SESSION_STATE_KIND: u32 = 31988; 23 24 /// Nostr event kind for AI session commands (parameterized replaceable, NIP-33). 25 /// Fire-and-forget commands to create sessions on remote hosts. 26 /// `d` tag = command UUID. 27 pub const AI_SESSION_COMMAND_KIND: u32 = 31989; 28 29 /// Extract the value of a named tag from a note. 30 pub fn get_tag_value<'a>(note: &'a nostrdb::Note<'a>, tag_name: &str) -> Option<&'a str> { 31 for tag in note.tags() { 32 if tag.count() < 2 { 33 continue; 34 } 35 let Some(name) = tag.get_str(0) else { 36 continue; 37 }; 38 if name != tag_name { 39 continue; 40 } 41 return tag.get_str(1); 42 } 43 None 44 } 45 46 /// A built nostr event ready for ingestion and relay publishing. 47 #[derive(Debug)] 48 pub struct BuiltEvent { 49 /// The bare event JSON `{…}` — for relay publishing and ndb ingestion. 50 pub note_json: String, 51 /// The 32-byte note ID (from the signed event). 52 pub note_id: [u8; 32], 53 /// The nostr event kind (1988 or 1989). 54 pub kind: u32, 55 } 56 57 impl BuiltEvent { 58 /// Format as `["EVENT", {…}]` for ndb ingestion via `process_event_with`. 59 pub fn to_event_json(&self) -> String { 60 format!("[\"EVENT\", {}]", self.note_json) 61 } 62 } 63 64 /// Wrap an inner event in a kind-1080 PNS envelope for relay publishing. 65 /// 66 /// Encrypts the inner event JSON with the PNS conversation key and signs 67 /// the outer event with the PNS keypair. Returns the kind-1080 event JSON. 68 pub fn wrap_pns( 69 inner_json: &str, 70 pns_keys: &enostr::pns::PnsKeys, 71 ) -> Result<String, EventBuildError> { 72 let ciphertext = enostr::pns::encrypt(&pns_keys.conversation_key, inner_json) 73 .map_err(|e| EventBuildError::Serialize(format!("PNS encrypt: {e}")))?; 74 75 let pns_secret = pns_keys.keypair.secret_key.secret_bytes(); 76 let builder = init_note_builder(enostr::pns::PNS_KIND, &ciphertext, Some(now_secs())); 77 let event = finalize_built_event(builder, &pns_secret, enostr::pns::PNS_KIND)?; 78 Ok(event.note_json) 79 } 80 81 /// Maintains threading state across a session's events. 82 pub struct ThreadingState { 83 /// Maps JSONL uuid → nostr note ID (32 bytes). 84 uuid_to_note_id: HashMap<String, [u8; 32]>, 85 /// The note ID of the first event in the session (root). 86 root_note_id: Option<[u8; 32]>, 87 /// The note ID of the most recently built event. 88 last_note_id: Option<[u8; 32]>, 89 /// Monotonic sequence counter for unambiguous ordering. 90 seq: u32, 91 /// Last seen session ID (carried forward for lines that lack it). 92 session_id: Option<String>, 93 /// Last seen timestamp in seconds (carried forward for lines that lack it). 94 last_timestamp: Option<u64>, 95 } 96 97 impl Default for ThreadingState { 98 fn default() -> Self { 99 Self::new() 100 } 101 } 102 103 impl ThreadingState { 104 pub fn new() -> Self { 105 Self { 106 uuid_to_note_id: HashMap::new(), 107 root_note_id: None, 108 last_note_id: None, 109 seq: 0, 110 session_id: None, 111 last_timestamp: None, 112 } 113 } 114 115 /// The current sequence number. 116 pub fn seq(&self) -> u32 { 117 self.seq 118 } 119 120 /// Update session context from a JSONL line (session_id, timestamp). 121 fn update_context(&mut self, line: &JsonlLine) { 122 if let Some(sid) = line.session_id() { 123 self.session_id = Some(sid.to_string()); 124 } 125 if let Some(ts) = line.timestamp_secs() { 126 self.last_timestamp = Some(ts); 127 } 128 } 129 130 /// Get the session ID for the current line, falling back to the last seen. 131 fn session_id_for(&self, line: &JsonlLine) -> Option<String> { 132 line.session_id() 133 .map(|s| s.to_string()) 134 .or_else(|| self.session_id.clone()) 135 } 136 137 /// Get the timestamp for the current line, falling back to the last seen. 138 fn timestamp_for(&self, line: &JsonlLine) -> Option<u64> { 139 line.timestamp_secs().or(self.last_timestamp) 140 } 141 142 /// Seed threading state from existing events (e.g. loaded from ndb). 143 /// 144 /// Sets root and last note IDs so that subsequent live events 145 /// thread correctly as replies to the existing conversation. 146 pub fn seed(&mut self, root_note_id: [u8; 32], last_note_id: [u8; 32], event_count: u32) { 147 self.root_note_id = Some(root_note_id); 148 self.last_note_id = Some(last_note_id); 149 self.seq = event_count; 150 } 151 152 /// Record a built event's note ID, associated with a JSONL uuid. 153 /// 154 /// `can_be_root`: if true, this event may become the conversation root. 155 /// Metadata events (queue-operation, progress, etc.) should pass false 156 /// so they don't become the root of the threading chain. 157 pub fn record(&mut self, uuid: Option<&str>, note_id: [u8; 32], can_be_root: bool) { 158 if can_be_root && self.root_note_id.is_none() { 159 self.root_note_id = Some(note_id); 160 } 161 if let Some(uuid) = uuid { 162 self.uuid_to_note_id.insert(uuid.to_string(), note_id); 163 } 164 self.last_note_id = Some(note_id); 165 self.seq += 1; 166 } 167 } 168 169 /// Get the current Unix timestamp in seconds. 170 fn now_secs() -> u64 { 171 std::time::SystemTime::now() 172 .duration_since(std::time::UNIX_EPOCH) 173 .unwrap_or_default() 174 .as_secs() 175 } 176 177 /// Initialize a NoteBuilder with kind, content, and optional timestamp. 178 fn init_note_builder(kind: u32, content: &str, timestamp: Option<u64>) -> NoteBuilder<'_> { 179 let mut builder = NoteBuilder::new() 180 .kind(kind) 181 .content(content) 182 .options(NoteBuildOptions::default()); 183 184 if let Some(ts) = timestamp { 185 builder = builder.created_at(ts); 186 } 187 188 builder 189 } 190 191 /// Sign, build, and serialize a NoteBuilder into a BuiltEvent. 192 fn finalize_built_event( 193 builder: NoteBuilder, 194 secret_key: &[u8; 32], 195 kind: u32, 196 ) -> Result<BuiltEvent, EventBuildError> { 197 let note = builder 198 .sign(secret_key) 199 .build() 200 .ok_or_else(|| EventBuildError::Build("NoteBuilder::build returned None".to_string()))?; 201 202 let note_id: [u8; 32] = *note.id(); 203 204 let note_json = note 205 .json() 206 .map_err(|e| EventBuildError::Serialize(format!("{:?}", e)))?; 207 208 Ok(BuiltEvent { 209 note_json, 210 note_id, 211 kind, 212 }) 213 } 214 215 /// Whether a role represents a conversation message (not metadata). 216 pub fn is_conversation_role(role: &str) -> bool { 217 matches!(role, "user" | "assistant" | "tool_call" | "tool_result") 218 } 219 220 /// Build nostr events from a single JSONL line. 221 /// 222 /// Returns one or more events. Assistant messages with mixed content blocks 223 /// (text + tool_use) are split into multiple events, one per block. 224 /// 225 /// `secret_key` is the 32-byte secret key for signing events. 226 pub fn build_events( 227 line: &JsonlLine, 228 threading: &mut ThreadingState, 229 secret_key: &[u8; 32], 230 ) -> Result<Vec<BuiltEvent>, EventBuildError> { 231 // Resolve session_id and timestamp with fallback to last seen values, 232 // then update context for subsequent lines. 233 let session_id = threading.session_id_for(line); 234 let timestamp = threading.timestamp_for(line); 235 threading.update_context(line); 236 237 let msg = line.message(); 238 let is_assistant = line.line_type() == Some("assistant"); 239 240 // Check if this is an assistant message with multiple content blocks 241 // that should be split into separate events 242 let blocks: Vec<ContentBlock<'_>> = if is_assistant { 243 msg.as_ref().map(|m| m.content_blocks()).unwrap_or_default() 244 } else { 245 vec![] 246 }; 247 248 let should_split = is_assistant && blocks.len() > 1; 249 250 let mut events = if should_split { 251 // Build one event per content block 252 let total = blocks.len(); 253 let mut events = Vec::with_capacity(total); 254 for (i, block) in blocks.iter().enumerate() { 255 let content = session_jsonl::display_content_for_block(block); 256 let role = match block { 257 ContentBlock::Text(_) => "assistant", 258 ContentBlock::ToolUse { .. } => "tool_call", 259 ContentBlock::ToolResult { .. } => "tool_result", 260 }; 261 let tool_id = match block { 262 ContentBlock::ToolUse { id, .. } => Some(*id), 263 ContentBlock::ToolResult { tool_use_id, .. } => Some(*tool_use_id), 264 _ => None, 265 }; 266 let tool_name = match block { 267 ContentBlock::ToolUse { name, .. } => Some(*name), 268 ContentBlock::ToolResult { tool_use_id, .. } => { 269 // Look up tool name from a prior ToolUse block with matching id 270 blocks.iter().find_map(|b| match b { 271 ContentBlock::ToolUse { id, name, .. } if *id == *tool_use_id => { 272 Some(*name) 273 } 274 _ => None, 275 }) 276 } 277 _ => None, 278 }; 279 280 let event = build_single_event( 281 Some(line), 282 &content, 283 role, 284 "claude-code", 285 Some((i, total)), 286 tool_id, 287 tool_name, 288 session_id.as_deref(), 289 None, 290 timestamp, 291 threading, 292 secret_key, 293 )?; 294 threading.record(line.uuid(), event.note_id, is_conversation_role(role)); 295 events.push(event); 296 } 297 events 298 } else { 299 // Single event for the line 300 let content = session_jsonl::extract_display_content(line); 301 let role = line.role().unwrap_or("unknown"); 302 303 // Extract tool_id and tool_name from single-block messages 304 let (tool_id, tool_name) = msg 305 .as_ref() 306 .and_then(|m| { 307 let blocks = m.content_blocks(); 308 if blocks.len() == 1 { 309 match &blocks[0] { 310 ContentBlock::ToolUse { id, name, .. } => { 311 Some((id.to_string(), Some(name.to_string()))) 312 } 313 ContentBlock::ToolResult { tool_use_id, .. } => { 314 Some((tool_use_id.to_string(), None)) 315 } 316 _ => None, 317 } 318 } else { 319 None 320 } 321 }) 322 .map_or((None, None), |(id, name)| (Some(id), name)); 323 324 let event = build_single_event( 325 Some(line), 326 &content, 327 role, 328 "claude-code", 329 None, 330 tool_id.as_deref(), 331 tool_name.as_deref(), 332 session_id.as_deref(), 333 None, 334 timestamp, 335 threading, 336 secret_key, 337 )?; 338 threading.record(line.uuid(), event.note_id, is_conversation_role(role)); 339 vec![event] 340 }; 341 342 // Build a kind-1989 source-data companion event linked to the first 1988 event. 343 let first_note_id = events[0].note_id; 344 let source_data_event = build_source_data_event( 345 line, 346 &first_note_id, 347 threading.seq() - 1, 348 session_id.as_deref(), 349 timestamp, 350 secret_key, 351 )?; 352 events.push(source_data_event); 353 354 Ok(events) 355 } 356 357 #[derive(Debug)] 358 pub enum EventBuildError { 359 Build(String), 360 Serialize(String), 361 } 362 363 impl std::fmt::Display for EventBuildError { 364 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 365 match self { 366 EventBuildError::Build(e) => write!(f, "failed to build note: {}", e), 367 EventBuildError::Serialize(e) => write!(f, "failed to serialize event: {}", e), 368 } 369 } 370 } 371 372 /// Build a kind-1989 source-data companion event. 373 /// 374 /// Contains the raw JSONL line and links to the corresponding 1988 event. 375 /// Does NOT participate in threading (no root/reply, no seq increment). 376 fn build_source_data_event( 377 line: &JsonlLine, 378 conversation_note_id: &[u8; 32], 379 seq: u32, 380 session_id: Option<&str>, 381 timestamp: Option<u64>, 382 secret_key: &[u8; 32], 383 ) -> Result<BuiltEvent, EventBuildError> { 384 let raw_json = line.to_json(); 385 let seq_str = seq.to_string(); 386 387 let mut builder = init_note_builder(AI_SOURCE_DATA_KIND, "", timestamp); 388 389 // Link to the corresponding 1988 event 390 builder = builder 391 .start_tag() 392 .tag_str("e") 393 .tag_id(conversation_note_id); 394 395 if let Some(session_id) = session_id { 396 builder = builder.start_tag().tag_str("d").tag_str(session_id); 397 } 398 399 // Same seq as the first 1988 event from this line 400 builder = builder.start_tag().tag_str("seq").tag_str(&seq_str); 401 402 // The raw JSONL data 403 builder = builder 404 .start_tag() 405 .tag_str("source-data") 406 .tag_str(&raw_json); 407 408 finalize_built_event(builder, secret_key, AI_SOURCE_DATA_KIND) 409 } 410 411 /// Build a single kind-1988 nostr event. 412 /// 413 /// When `line` is provided (archive path), extracts slug, version, model, 414 /// line_type, and cwd from the JSONL line. When `None` (live path), only 415 /// uses the explicitly passed parameters. 416 /// 417 /// `split_index`: `Some((i, total))` when this event is part of a split 418 /// assistant message. 419 /// 420 /// `tool_id`: The tool use/result ID for tool_call and tool_result events. 421 /// 422 /// `tool_name`: The tool name (e.g. "Bash", "Read") for tool_call and tool_result events. 423 #[allow(clippy::too_many_arguments)] 424 fn build_single_event( 425 line: Option<&JsonlLine>, 426 content: &str, 427 role: &str, 428 source: &str, 429 split_index: Option<(usize, usize)>, 430 tool_id: Option<&str>, 431 tool_name: Option<&str>, 432 session_id: Option<&str>, 433 cwd: Option<&str>, 434 timestamp: Option<u64>, 435 threading: &ThreadingState, 436 secret_key: &[u8; 32], 437 ) -> Result<BuiltEvent, EventBuildError> { 438 let mut builder = init_note_builder(AI_CONVERSATION_KIND, content, timestamp); 439 440 // -- Session identity tags -- 441 if let Some(session_id) = session_id { 442 builder = builder.start_tag().tag_str("d").tag_str(session_id); 443 } 444 if let Some(slug) = line.and_then(|l| l.slug()) { 445 builder = builder.start_tag().tag_str("session-slug").tag_str(slug); 446 } 447 448 // -- Threading tags (NIP-10) -- 449 if let Some(root_id) = threading.root_note_id { 450 builder = builder 451 .start_tag() 452 .tag_str("e") 453 .tag_id(&root_id) 454 .tag_str("") 455 .tag_str("root"); 456 } 457 if let Some(reply_id) = threading.last_note_id { 458 builder = builder 459 .start_tag() 460 .tag_str("e") 461 .tag_id(&reply_id) 462 .tag_str("") 463 .tag_str("reply"); 464 } 465 466 // -- Sequence number (monotonic, for unambiguous ordering) -- 467 let seq_str = threading.seq.to_string(); 468 builder = builder.start_tag().tag_str("seq").tag_str(&seq_str); 469 470 // -- Message metadata tags -- 471 builder = builder.start_tag().tag_str("source").tag_str(source); 472 473 if let Some(version) = line.and_then(|l| l.version()) { 474 builder = builder 475 .start_tag() 476 .tag_str("source-version") 477 .tag_str(version); 478 } 479 480 builder = builder.start_tag().tag_str("role").tag_str(role); 481 482 // Model tag (for assistant messages) 483 if let Some(model) = line.and_then(|l| l.message()).and_then(|m| m.model()) { 484 builder = builder.start_tag().tag_str("model").tag_str(model); 485 } 486 487 if let Some(line_type) = line.and_then(|l| l.line_type()) { 488 builder = builder.start_tag().tag_str("turn-type").tag_str(line_type); 489 } 490 491 // -- CWD tag -- 492 let resolved_cwd = cwd.or_else(|| line.and_then(|l| l.cwd())); 493 if let Some(cwd) = resolved_cwd { 494 builder = builder.start_tag().tag_str("cwd").tag_str(cwd); 495 } 496 497 // -- Split tag (for split assistant messages) -- 498 if let Some((i, total)) = split_index { 499 let split_str = format!("{}/{}", i, total); 500 builder = builder.start_tag().tag_str("split").tag_str(&split_str); 501 } 502 503 // -- Tool ID tag -- 504 if let Some(tid) = tool_id { 505 builder = builder.start_tag().tag_str("tool-id").tag_str(tid); 506 } 507 508 // -- Tool name tag -- 509 if let Some(tn) = tool_name { 510 builder = builder.start_tag().tag_str("tool-name").tag_str(tn); 511 } 512 513 // -- Discoverability -- 514 builder = builder.start_tag().tag_str("t").tag_str("ai-conversation"); 515 516 finalize_built_event(builder, secret_key, AI_CONVERSATION_KIND) 517 } 518 519 /// Build a kind-1988 event for a live conversation message. 520 /// 521 /// Unlike `build_events()` which works from JSONL lines, this builds directly 522 /// from role + content strings. No kind-1989 source-data events are created. 523 /// 524 /// Calls `threading.record()` internally. 525 #[allow(clippy::too_many_arguments)] 526 pub fn build_live_event( 527 content: &str, 528 role: &str, 529 session_id: &str, 530 cwd: Option<&str>, 531 tool_id: Option<&str>, 532 tool_name: Option<&str>, 533 threading: &mut ThreadingState, 534 secret_key: &[u8; 32], 535 ) -> Result<BuiltEvent, EventBuildError> { 536 let event = build_single_event( 537 None, 538 content, 539 role, 540 "notedeck-dave", 541 None, 542 tool_id, 543 tool_name, 544 Some(session_id), 545 cwd, 546 Some(now_secs()), 547 threading, 548 secret_key, 549 )?; 550 551 threading.record(None, event.note_id, true); 552 Ok(event) 553 } 554 555 /// Build a kind-1988 permission request event. 556 /// 557 /// Published to relays so remote clients (phone) can see pending permission 558 /// requests and respond. Tags include `perm-id` (UUID), `tool-name`, and 559 /// `t: ai-permission` for filtering. 560 /// 561 /// Maximum serialized size for tool_input in permission request events. 562 /// Keeps the final PNS-wrapped event well under typical relay limits 563 /// (~64KB). Budget: 40KB content + ~500B inner event overhead + ~500B 564 /// PNS outer overhead, with 1.33x base64 expansion ≈ 54KB total. 565 const MAX_TOOL_INPUT_BYTES: usize = 40_000; 566 567 /// Truncate large string values in a tool_input JSON object so that the 568 /// serialized result fits within `max_bytes`. 569 /// 570 /// Only modifies top-level string fields in an Object value. Fields are 571 /// truncated proportionally based on how much they exceed their share of 572 /// the budget. A `"_truncated": true` flag is added when any field is 573 /// shortened. 574 fn truncate_tool_input(tool_input: &serde_json::Value, max_bytes: usize) -> serde_json::Value { 575 use serde_json::Value; 576 577 let serialized = serde_json::to_string(tool_input).unwrap_or_default(); 578 if serialized.len() <= max_bytes { 579 return tool_input.clone(); 580 } 581 582 let obj = match tool_input.as_object() { 583 Some(o) => o, 584 None => return tool_input.clone(), 585 }; 586 587 // Collect string field names sorted largest-first so we know what to trim 588 let mut string_fields: Vec<(&str, usize)> = obj 589 .iter() 590 .filter_map(|(k, v)| v.as_str().map(|s| (k.as_str(), s.len()))) 591 .collect(); 592 string_fields.sort_by(|a, b| b.1.cmp(&a.1)); 593 594 if string_fields.is_empty() { 595 return tool_input.clone(); 596 } 597 598 let excess = serialized.len() - max_bytes; 599 let suffix = "\n... (truncated)"; 600 let suffix_len = suffix.len(); 601 // Safety margin for JSON escaping differences and the added _truncated field 602 let trim_target = excess + suffix_len * string_fields.len() + 64; 603 604 // Distribute the trim proportionally among string fields 605 let total_string_bytes: usize = string_fields.iter().map(|(_, len)| len).sum(); 606 let mut trim_amounts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new(); 607 for (key, len) in &string_fields { 608 let share = (*len as f64 / total_string_bytes as f64 * trim_target as f64).ceil() as usize; 609 trim_amounts.insert(key, share.min(*len)); 610 } 611 612 let mut result = serde_json::Map::new(); 613 let mut did_truncate = false; 614 for (key, val) in obj { 615 if let Some(s) = val.as_str() { 616 let trim = trim_amounts.get(key.as_str()).copied().unwrap_or(0); 617 if trim > 0 && s.len() > suffix_len + trim { 618 let keep = s.len() - trim; 619 let cut = notedeck::abbrev::floor_char_boundary(s, keep); 620 let truncated = format!("{}{}", &s[..cut], suffix); 621 result.insert(key.clone(), Value::String(truncated)); 622 did_truncate = true; 623 } else { 624 result.insert(key.clone(), val.clone()); 625 } 626 } else { 627 result.insert(key.clone(), val.clone()); 628 } 629 } 630 631 if did_truncate { 632 result.insert("_truncated".to_string(), Value::Bool(true)); 633 } 634 Value::Object(result) 635 } 636 637 /// Does NOT participate in threading — permission events are ancillary. 638 pub fn build_permission_request_event( 639 perm_id: &uuid::Uuid, 640 tool_name: &str, 641 tool_input: &serde_json::Value, 642 session_id: &str, 643 secret_key: &[u8; 32], 644 ) -> Result<BuiltEvent, EventBuildError> { 645 // Truncate large string values so the event fits within relay size 646 // limits after PNS wrapping. The local UI keeps the full tool_input. 647 let tool_input_for_event = truncate_tool_input(tool_input, MAX_TOOL_INPUT_BYTES); 648 649 let content = serde_json::json!({ 650 "tool_name": tool_name, 651 "tool_input": tool_input_for_event, 652 }) 653 .to_string(); 654 655 let perm_id_str = perm_id.to_string(); 656 657 let mut builder = init_note_builder(AI_CONVERSATION_KIND, &content, Some(now_secs())); 658 659 // Session identity 660 builder = builder.start_tag().tag_str("d").tag_str(session_id); 661 662 // Permission-specific tags 663 builder = builder.start_tag().tag_str("perm-id").tag_str(&perm_id_str); 664 builder = builder.start_tag().tag_str("tool-name").tag_str(tool_name); 665 builder = builder 666 .start_tag() 667 .tag_str("role") 668 .tag_str("permission_request"); 669 builder = builder 670 .start_tag() 671 .tag_str("source") 672 .tag_str("notedeck-dave"); 673 674 // Discoverability 675 builder = builder.start_tag().tag_str("t").tag_str("ai-conversation"); 676 builder = builder.start_tag().tag_str("t").tag_str("ai-permission"); 677 678 finalize_built_event(builder, secret_key, AI_CONVERSATION_KIND) 679 } 680 681 /// Build a kind-1988 permission response event. 682 /// 683 /// Published by remote clients (phone) to allow/deny a permission request. 684 /// The desktop subscribes for these and routes them through the existing 685 /// oneshot channel, racing with the local UI. 686 /// 687 /// Tags include `perm-id` (matching the request), `e` tag linking to the 688 /// request event, and `t: ai-permission` for filtering. 689 pub fn build_permission_response_event( 690 perm_id: &uuid::Uuid, 691 request_note_id: &[u8; 32], 692 allowed: bool, 693 message: Option<&str>, 694 session_id: &str, 695 secret_key: &[u8; 32], 696 ) -> Result<BuiltEvent, EventBuildError> { 697 let content = serde_json::json!({ 698 "decision": if allowed { "allow" } else { "deny" }, 699 "message": message.unwrap_or(""), 700 }) 701 .to_string(); 702 703 let perm_id_str = perm_id.to_string(); 704 705 let mut builder = init_note_builder(AI_CONVERSATION_KIND, &content, Some(now_secs())); 706 707 // Session identity 708 builder = builder.start_tag().tag_str("d").tag_str(session_id); 709 710 // Link to the request event 711 builder = builder.start_tag().tag_str("e").tag_id(request_note_id); 712 713 // Permission-specific tags 714 builder = builder.start_tag().tag_str("perm-id").tag_str(&perm_id_str); 715 builder = builder 716 .start_tag() 717 .tag_str("role") 718 .tag_str("permission_response"); 719 builder = builder 720 .start_tag() 721 .tag_str("source") 722 .tag_str("notedeck-dave"); 723 724 // Discoverability 725 builder = builder.start_tag().tag_str("t").tag_str("ai-conversation"); 726 builder = builder.start_tag().tag_str("t").tag_str("ai-permission"); 727 728 finalize_built_event(builder, secret_key, AI_CONVERSATION_KIND) 729 } 730 731 /// Build a kind-31988 session state event (parameterized replaceable). 732 /// 733 /// Published on every status change so remote clients and startup restore 734 /// can discover active sessions. nostrdb auto-replaces older versions 735 /// with same (kind, pubkey, d-tag). 736 #[allow(clippy::too_many_arguments)] 737 pub fn build_session_state_event( 738 event_session_id: &str, 739 title: &str, 740 custom_title: Option<&str>, 741 cwd: &str, 742 status: &str, 743 indicator: Option<&str>, 744 hostname: &str, 745 home_dir: &str, 746 backend: &str, 747 permission_mode: &str, 748 cli_session_id: Option<&str>, 749 secret_key: &[u8; 32], 750 ) -> Result<BuiltEvent, EventBuildError> { 751 let mut builder = init_note_builder(AI_SESSION_STATE_KIND, "", Some(now_secs())); 752 753 // Session identity (makes this a parameterized replaceable event) 754 builder = builder.start_tag().tag_str("d").tag_str(event_session_id); 755 756 // Session metadata as tags 757 builder = builder.start_tag().tag_str("title").tag_str(title); 758 if let Some(ct) = custom_title { 759 builder = builder.start_tag().tag_str("custom_title").tag_str(ct); 760 } 761 builder = builder.start_tag().tag_str("cwd").tag_str(cwd); 762 builder = builder.start_tag().tag_str("status").tag_str(status); 763 if let Some(ind) = indicator { 764 builder = builder.start_tag().tag_str("indicator").tag_str(ind); 765 } 766 builder = builder.start_tag().tag_str("hostname").tag_str(hostname); 767 builder = builder.start_tag().tag_str("home_dir").tag_str(home_dir); 768 builder = builder.start_tag().tag_str("backend").tag_str(backend); 769 builder = builder 770 .start_tag() 771 .tag_str("permission-mode") 772 .tag_str(permission_mode); 773 774 // Real Claude CLI session ID for backend --resume. 775 // Empty string means the backend hasn't started yet. 776 // Absent (old events) means the d-tag itself is the CLI ID. 777 builder = builder 778 .start_tag() 779 .tag_str("cli_session") 780 .tag_str(cli_session_id.unwrap_or("")); 781 782 // Discoverability 783 builder = builder.start_tag().tag_str("t").tag_str("ai-session-state"); 784 builder = builder.start_tag().tag_str("t").tag_str("ai-conversation"); 785 builder = builder 786 .start_tag() 787 .tag_str("source") 788 .tag_str("notedeck-dave"); 789 790 finalize_built_event(builder, secret_key, AI_SESSION_STATE_KIND) 791 } 792 793 /// Build a kind-31989 spawn command event. 794 /// 795 /// This is a fire-and-forget command that tells a remote host to create a new 796 /// session. The target host discovers the command via its ndb subscription, 797 /// creates the session locally, and publishes a kind-31988 state event. 798 pub fn build_spawn_command_event( 799 target_host: &str, 800 cwd: &str, 801 backend: &str, 802 secret_key: &[u8; 32], 803 ) -> Result<BuiltEvent, EventBuildError> { 804 let command_id = uuid::Uuid::new_v4().to_string(); 805 let mut builder = init_note_builder(AI_SESSION_COMMAND_KIND, "", Some(now_secs())); 806 807 builder = builder.start_tag().tag_str("d").tag_str(&command_id); 808 builder = builder 809 .start_tag() 810 .tag_str("command") 811 .tag_str("spawn_session"); 812 builder = builder 813 .start_tag() 814 .tag_str("target_host") 815 .tag_str(target_host); 816 builder = builder.start_tag().tag_str("cwd").tag_str(cwd); 817 builder = builder.start_tag().tag_str("backend").tag_str(backend); 818 builder = builder 819 .start_tag() 820 .tag_str("t") 821 .tag_str("ai-session-command"); 822 builder = builder 823 .start_tag() 824 .tag_str("source") 825 .tag_str("notedeck-dave"); 826 827 finalize_built_event(builder, secret_key, AI_SESSION_COMMAND_KIND) 828 } 829 830 /// Build a kind-1988 command event to set permission mode on a remote session. 831 /// 832 /// Published by remote observers to request a permission mode change on the host. 833 /// The host subscribes for these and applies them via its local backend. 834 pub fn build_set_permission_mode_event( 835 mode: &str, 836 session_id: &str, 837 secret_key: &[u8; 32], 838 ) -> Result<BuiltEvent, EventBuildError> { 839 let content = serde_json::json!({ 840 "mode": mode, 841 }) 842 .to_string(); 843 844 let mut builder = init_note_builder(AI_CONVERSATION_KIND, &content, Some(now_secs())); 845 846 builder = builder.start_tag().tag_str("d").tag_str(session_id); 847 builder = builder 848 .start_tag() 849 .tag_str("role") 850 .tag_str("set_permission_mode"); 851 builder = builder 852 .start_tag() 853 .tag_str("source") 854 .tag_str("notedeck-dave"); 855 builder = builder.start_tag().tag_str("t").tag_str("ai-conversation"); 856 builder = builder.start_tag().tag_str("t").tag_str("ai-command"); 857 858 finalize_built_event(builder, secret_key, AI_CONVERSATION_KIND) 859 } 860 861 #[cfg(test)] 862 mod tests { 863 use super::*; 864 865 // Test secret key (32 bytes, not for real use) 866 fn test_secret_key() -> [u8; 32] { 867 let mut key = [0u8; 32]; 868 key[0] = 1; // non-zero so signing works 869 key 870 } 871 872 #[test] 873 fn test_build_user_text_event() { 874 let line = JsonlLine::parse( 875 r#"{"type":"user","uuid":"u1","parentUuid":null,"sessionId":"sess1","timestamp":"2026-02-09T20:43:35.675Z","cwd":"/tmp/project","version":"2.0.64","message":{"role":"user","content":"Human: hello world\n\n"}}"#, 876 ) 877 .unwrap(); 878 879 let mut threading = ThreadingState::new(); 880 let events = build_events(&line, &mut threading, &test_secret_key()).unwrap(); 881 882 // 1 conversation event (1988) + 1 source-data event (1989) 883 assert_eq!(events.len(), 2); 884 assert_eq!(events[0].kind, AI_CONVERSATION_KIND); 885 assert_eq!(events[1].kind, AI_SOURCE_DATA_KIND); 886 assert!(threading.root_note_id.is_some()); 887 assert_eq!(threading.root_note_id, Some(events[0].note_id)); 888 889 // 1988 event has kind and tags but NO source-data 890 let json = &events[0].note_json; 891 assert!(json.contains("1988")); 892 assert!(json.contains("source")); 893 assert!(json.contains("claude-code")); 894 assert!(json.contains("role")); 895 assert!(json.contains("user")); 896 assert!(!json.contains("source-data")); 897 898 // 1989 event has source-data 899 assert!(events[1].note_json.contains("source-data")); 900 } 901 902 #[test] 903 fn test_build_assistant_text_event() { 904 let line = JsonlLine::parse( 905 r#"{"type":"assistant","uuid":"u2","parentUuid":"u1","sessionId":"sess1","timestamp":"2026-02-09T20:43:38.421Z","cwd":"/tmp/project","version":"2.0.64","message":{"role":"assistant","model":"claude-opus-4-5-20251101","content":[{"type":"text","text":"I can help with that."}]}}"#, 906 ) 907 .unwrap(); 908 909 let mut threading = ThreadingState::new(); 910 // Simulate a prior event 911 threading.root_note_id = Some([1u8; 32]); 912 threading.last_note_id = Some([1u8; 32]); 913 914 let events = build_events(&line, &mut threading, &test_secret_key()).unwrap(); 915 // 1 conversation (1988) + 1 source-data (1989) 916 assert_eq!(events.len(), 2); 917 assert_eq!(events[0].kind, AI_CONVERSATION_KIND); 918 assert_eq!(events[1].kind, AI_SOURCE_DATA_KIND); 919 920 let json = &events[0].note_json; 921 assert!(json.contains("assistant")); 922 assert!(json.contains("claude-opus-4-5-20251101")); // model tag 923 } 924 925 #[test] 926 fn test_build_split_assistant_mixed_content() { 927 let line = JsonlLine::parse( 928 r#"{"type":"assistant","uuid":"u3","sessionId":"sess1","timestamp":"2026-02-09T20:00:00Z","cwd":"/tmp","version":"2.0.64","message":{"role":"assistant","model":"claude-opus-4-5-20251101","content":[{"type":"text","text":"Let me check."},{"type":"tool_use","id":"t1","name":"Read","input":{"file_path":"/tmp/test.rs"}}]}}"#, 929 ) 930 .unwrap(); 931 932 let mut threading = ThreadingState::new(); 933 let events = build_events(&line, &mut threading, &test_secret_key()).unwrap(); 934 935 // 2 conversation events (1988) + 1 source-data (1989) 936 assert_eq!(events.len(), 3); 937 assert_eq!(events[0].kind, AI_CONVERSATION_KIND); 938 assert_eq!(events[1].kind, AI_CONVERSATION_KIND); 939 assert_eq!(events[2].kind, AI_SOURCE_DATA_KIND); 940 941 // All should have unique note IDs 942 assert_ne!(events[0].note_id, events[1].note_id); 943 assert_ne!(events[0].note_id, events[2].note_id); 944 } 945 946 #[test] 947 fn test_threading_chain() { 948 let lines = vec![ 949 r#"{"type":"user","uuid":"u1","parentUuid":null,"sessionId":"s","timestamp":"2026-02-09T20:00:00Z","cwd":"/tmp","version":"2.0.64","message":{"role":"user","content":"hello"}}"#, 950 r#"{"type":"assistant","uuid":"u2","parentUuid":"u1","sessionId":"s","timestamp":"2026-02-09T20:00:01Z","cwd":"/tmp","version":"2.0.64","message":{"role":"assistant","content":[{"type":"text","text":"hi"}]}}"#, 951 r#"{"type":"user","uuid":"u3","parentUuid":"u2","sessionId":"s","timestamp":"2026-02-09T20:00:02Z","cwd":"/tmp","version":"2.0.64","message":{"role":"user","content":"bye"}}"#, 952 ]; 953 954 let mut threading = ThreadingState::new(); 955 let sk = test_secret_key(); 956 let mut all_events = vec![]; 957 958 for line_str in &lines { 959 let line = JsonlLine::parse(line_str).unwrap(); 960 let events = build_events(&line, &mut threading, &sk).unwrap(); 961 all_events.extend(events); 962 } 963 964 // 3 lines × (1 conversation + 1 source-data) = 6 events 965 assert_eq!(all_events.len(), 6); 966 967 // Filter to only 1988 events for threading checks 968 let conv_events: Vec<_> = all_events 969 .iter() 970 .filter(|e| e.kind == AI_CONVERSATION_KIND) 971 .collect(); 972 assert_eq!(conv_events.len(), 3); 973 974 // First event should be root (no e tags) 975 // Subsequent events should reference root + previous 976 assert!(!conv_events[0].note_json.contains("root")); 977 assert!(conv_events[1].note_json.contains("root")); 978 assert!(conv_events[1].note_json.contains("reply")); 979 assert!(conv_events[2].note_json.contains("root")); 980 assert!(conv_events[2].note_json.contains("reply")); 981 } 982 983 #[test] 984 fn test_source_data_preserves_raw_json() { 985 let line = JsonlLine::parse( 986 r#"{"type":"user","uuid":"u1","sessionId":"s","timestamp":"2026-02-09T20:00:00Z","cwd":"/Users/jb55/dev/notedeck","version":"2.0.64","message":{"role":"user","content":"check /Users/jb55/dev/notedeck/src/main.rs"}}"#, 987 ) 988 .unwrap(); 989 990 let mut threading = ThreadingState::new(); 991 let events = build_events(&line, &mut threading, &test_secret_key()).unwrap(); 992 993 // 1988 event should NOT have source-data 994 assert!(!events[0].note_json.contains("source-data")); 995 996 // 1989 event should have source-data with raw paths preserved 997 let sd_event = events 998 .iter() 999 .find(|e| e.kind == AI_SOURCE_DATA_KIND) 1000 .unwrap(); 1001 assert!(sd_event.note_json.contains("source-data")); 1002 assert!(sd_event.note_json.contains("/Users/jb55/dev/notedeck")); 1003 } 1004 1005 #[test] 1006 fn test_queue_operation_event() { 1007 let line = JsonlLine::parse( 1008 r#"{"type":"queue-operation","operation":"dequeue","timestamp":"2026-02-09T20:43:35.669Z","sessionId":"sess1"}"#, 1009 ) 1010 .unwrap(); 1011 1012 let mut threading = ThreadingState::new(); 1013 let events = build_events(&line, &mut threading, &test_secret_key()).unwrap(); 1014 // 1 conversation (1988) + 1 source-data (1989) 1015 assert_eq!(events.len(), 2); 1016 1017 let json = &events[0].note_json; 1018 assert!(json.contains("queue-operation")); 1019 } 1020 1021 #[test] 1022 fn test_seq_counter_increments() { 1023 let lines = vec![ 1024 r#"{"type":"user","uuid":"u1","parentUuid":null,"sessionId":"s","timestamp":"2026-02-09T20:00:00Z","cwd":"/tmp","version":"2.0.64","message":{"role":"user","content":"hello"}}"#, 1025 r#"{"type":"assistant","uuid":"u2","parentUuid":"u1","sessionId":"s","timestamp":"2026-02-09T20:00:01Z","cwd":"/tmp","version":"2.0.64","message":{"role":"assistant","content":[{"type":"text","text":"hi"}]}}"#, 1026 ]; 1027 1028 let mut threading = ThreadingState::new(); 1029 let sk = test_secret_key(); 1030 1031 assert_eq!(threading.seq(), 0); 1032 1033 let line = JsonlLine::parse(lines[0]).unwrap(); 1034 let events = build_events(&line, &mut threading, &sk).unwrap(); 1035 // 1 conversation + 1 source-data 1036 assert_eq!(events.len(), 2); 1037 assert_eq!(threading.seq(), 1); 1038 // First 1988 event should have seq=0 1039 assert!(events[0].note_json.contains(r#""seq","0"#)); 1040 // 1989 event should also have seq=0 (matches its 1988 event) 1041 assert!(events[1].note_json.contains(r#""seq","0"#)); 1042 1043 let line = JsonlLine::parse(lines[1]).unwrap(); 1044 let events = build_events(&line, &mut threading, &sk).unwrap(); 1045 assert_eq!(events.len(), 2); 1046 assert_eq!(threading.seq(), 2); 1047 // Second 1988 event should have seq=1 1048 assert!(events[0].note_json.contains(r#""seq","1"#)); 1049 } 1050 1051 #[test] 1052 fn test_split_tags_and_source_data() { 1053 let line = JsonlLine::parse( 1054 r#"{"type":"assistant","uuid":"u3","sessionId":"sess1","timestamp":"2026-02-09T20:00:00Z","cwd":"/tmp","version":"2.0.64","message":{"role":"assistant","model":"claude-opus-4-5-20251101","content":[{"type":"text","text":"Let me check."},{"type":"tool_use","id":"t1","name":"Read","input":{"file_path":"/tmp/test.rs"}}]}}"#, 1055 ) 1056 .unwrap(); 1057 1058 let mut threading = ThreadingState::new(); 1059 let events = build_events(&line, &mut threading, &test_secret_key()).unwrap(); 1060 // 2 conversation (1988) + 1 source-data (1989) 1061 assert_eq!(events.len(), 3); 1062 1063 // First event (text): split 0/2, NO source-data (moved to 1989) 1064 assert!(events[0].note_json.contains(r#""split","0/2"#)); 1065 assert!(!events[0].note_json.contains("source-data")); 1066 1067 // Second event (tool_call): split 1/2, NO source-data, has tool-id 1068 assert!(events[1].note_json.contains(r#""split","1/2"#)); 1069 assert!(!events[1].note_json.contains("source-data")); 1070 assert!(events[1].note_json.contains(r#""tool-id","t1"#)); 1071 1072 // Third event (1989): has source-data 1073 assert_eq!(events[2].kind, AI_SOURCE_DATA_KIND); 1074 assert!(events[2].note_json.contains("source-data")); 1075 } 1076 1077 #[test] 1078 fn test_cwd_tag() { 1079 let line = JsonlLine::parse( 1080 r#"{"type":"user","uuid":"u1","sessionId":"s","timestamp":"2026-02-09T20:00:00Z","cwd":"/Users/jb55/dev/notedeck","version":"2.0.64","message":{"role":"user","content":"hello"}}"#, 1081 ) 1082 .unwrap(); 1083 1084 let mut threading = ThreadingState::new(); 1085 let events = build_events(&line, &mut threading, &test_secret_key()).unwrap(); 1086 1087 assert!(events[0] 1088 .note_json 1089 .contains(r#""cwd","/Users/jb55/dev/notedeck"#)); 1090 } 1091 1092 #[test] 1093 fn test_tool_result_has_tool_id() { 1094 let line = JsonlLine::parse( 1095 r#"{"type":"user","uuid":"u4","parentUuid":"u3","cwd":"/tmp","sessionId":"s","version":"2.0.64","timestamp":"2026-02-09T20:00:03Z","message":{"role":"user","content":[{"tool_use_id":"toolu_abc","type":"tool_result","content":"file contents"}]}}"#, 1096 ) 1097 .unwrap(); 1098 1099 let mut threading = ThreadingState::new(); 1100 let events = build_events(&line, &mut threading, &test_secret_key()).unwrap(); 1101 // 1 conversation + 1 source-data 1102 assert_eq!(events.len(), 2); 1103 assert!(events[0].note_json.contains(r#""tool-id","toolu_abc"#)); 1104 } 1105 1106 #[tokio::test] 1107 async fn test_full_roundtrip() { 1108 use crate::session_reconstructor; 1109 use nostrdb::{Config, IngestMetadata, Ndb, Transaction}; 1110 use serde_json::Value; 1111 use tempfile::TempDir; 1112 1113 // Sample JSONL lines covering different message types 1114 let jsonl_lines = vec![ 1115 r#"{"type":"queue-operation","operation":"dequeue","timestamp":"2026-02-09T20:00:00Z","sessionId":"roundtrip-test"}"#, 1116 r#"{"type":"user","uuid":"u1","parentUuid":null,"sessionId":"roundtrip-test","timestamp":"2026-02-09T20:00:01Z","cwd":"/tmp/project","version":"2.0.64","message":{"role":"user","content":"Human: hello world\n\n"}}"#, 1117 r#"{"type":"assistant","uuid":"u2","parentUuid":"u1","sessionId":"roundtrip-test","timestamp":"2026-02-09T20:00:02Z","cwd":"/tmp/project","version":"2.0.64","message":{"role":"assistant","model":"claude-opus-4-5-20251101","content":[{"type":"text","text":"Let me check that file."},{"type":"tool_use","id":"toolu_1","name":"Read","input":{"file_path":"/tmp/project/main.rs"}}]}}"#, 1118 r#"{"type":"user","uuid":"u3","parentUuid":"u2","sessionId":"roundtrip-test","timestamp":"2026-02-09T20:00:03Z","cwd":"/tmp/project","version":"2.0.64","message":{"role":"user","content":[{"tool_use_id":"toolu_1","type":"tool_result","content":"fn main() {}"}]}}"#, 1119 r#"{"type":"assistant","uuid":"u4","parentUuid":"u3","sessionId":"roundtrip-test","timestamp":"2026-02-09T20:00:04Z","cwd":"/tmp/project","version":"2.0.64","message":{"role":"assistant","model":"claude-opus-4-5-20251101","content":[{"type":"text","text":"That's a simple main function."}]}}"#, 1120 ]; 1121 1122 // Set up ndb 1123 let tmp_dir = TempDir::new().unwrap(); 1124 let ndb = Ndb::new(tmp_dir.path().to_str().unwrap(), &Config::new()).unwrap(); 1125 1126 // Build and ingest events one at a time, waiting for each 1127 let sk = test_secret_key(); 1128 let mut threading = ThreadingState::new(); 1129 let mut total_events = 0; 1130 1131 let filter = nostrdb::Filter::new() 1132 .kinds([AI_CONVERSATION_KIND as u64, AI_SOURCE_DATA_KIND as u64]) 1133 .build(); 1134 1135 for line_str in &jsonl_lines { 1136 let line = JsonlLine::parse(line_str).unwrap(); 1137 let events = build_events(&line, &mut threading, &sk).unwrap(); 1138 for event in &events { 1139 let sub_id = ndb.subscribe(&[filter.clone()]).unwrap(); 1140 ndb.process_event_with(&event.to_event_json(), IngestMetadata::new().client(true)) 1141 .expect("ingest failed"); 1142 let _keys = ndb.wait_for_notes(sub_id, 1).await.unwrap(); 1143 total_events += 1; 1144 } 1145 } 1146 1147 // Each JSONL line produces N conversation events + 1 source-data event. 1148 // Line 1 (queue-op): 1 conv + 1 sd = 2 1149 // Line 2 (user): 1 conv + 1 sd = 2 1150 // Line 3 (assistant split): 2 conv + 1 sd = 3 1151 // Line 4 (user tool_result): 1 conv + 1 sd = 2 1152 // Line 5 (assistant): 1 conv + 1 sd = 2 1153 // Total: 11 1154 assert_eq!(total_events, 11); 1155 1156 // Reconstruct JSONL from ndb 1157 let txn = Transaction::new(&ndb).unwrap(); 1158 let reconstructed = 1159 session_reconstructor::reconstruct_jsonl_lines(&ndb, &txn, "roundtrip-test").unwrap(); 1160 1161 // Should get back one JSONL line per original line 1162 assert_eq!( 1163 reconstructed.len(), 1164 jsonl_lines.len(), 1165 "expected {} lines, got {}", 1166 jsonl_lines.len(), 1167 reconstructed.len() 1168 ); 1169 1170 // Compare each line as serde_json::Value for order-independent equality 1171 for (i, (original, reconstructed)) in 1172 jsonl_lines.iter().zip(reconstructed.iter()).enumerate() 1173 { 1174 let orig_val: Value = serde_json::from_str(original).unwrap(); 1175 let recon_val: Value = serde_json::from_str(reconstructed).unwrap(); 1176 assert_eq!( 1177 orig_val, recon_val, 1178 "line {} mismatch.\noriginal: {}\nreconstructed: {}", 1179 i, original, reconstructed 1180 ); 1181 } 1182 } 1183 1184 #[test] 1185 fn test_file_history_snapshot_inherits_context() { 1186 // file-history-snapshot lines lack sessionId and top-level timestamp. 1187 // They should inherit session_id from a prior line and get timestamp 1188 // from snapshot.timestamp. 1189 let lines = vec![ 1190 r#"{"type":"user","uuid":"u1","parentUuid":null,"sessionId":"ctx-test","timestamp":"2026-02-09T20:00:00Z","cwd":"/tmp","version":"2.0.64","message":{"role":"user","content":"hello"}}"#, 1191 r#"{"type":"file-history-snapshot","messageId":"abc","snapshot":{"messageId":"abc","trackedFileBackups":{},"timestamp":"2026-02-11T01:29:31.555Z"},"isSnapshotUpdate":false}"#, 1192 ]; 1193 1194 let mut threading = ThreadingState::new(); 1195 let sk = test_secret_key(); 1196 1197 // First line sets context 1198 let line = JsonlLine::parse(lines[0]).unwrap(); 1199 let events = build_events(&line, &mut threading, &sk).unwrap(); 1200 assert!(events[0].note_json.contains(r#""d","ctx-test"#)); 1201 1202 // Second line (file-history-snapshot) should inherit session_id 1203 let line = JsonlLine::parse(lines[1]).unwrap(); 1204 assert!(line.session_id().is_none()); // no top-level sessionId 1205 let events = build_events(&line, &mut threading, &sk).unwrap(); 1206 1207 // 1988 event should have inherited d tag 1208 assert!(events[0].note_json.contains(r#""d","ctx-test"#)); 1209 // Should have snapshot timestamp (1770773371), not the user's 1210 assert!(events[0].note_json.contains(r#""created_at":1770773371"#)); 1211 } 1212 1213 #[test] 1214 fn test_build_permission_request_event() { 1215 let perm_id = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); 1216 let tool_input = serde_json::json!({"command": "rm -rf /tmp/test"}); 1217 let sk = test_secret_key(); 1218 1219 let event = 1220 build_permission_request_event(&perm_id, "Bash", &tool_input, "sess-perm-test", &sk) 1221 .unwrap(); 1222 1223 assert_eq!(event.kind, AI_CONVERSATION_KIND); 1224 1225 let json = &event.note_json; 1226 // Has permission-specific tags 1227 assert!(json.contains(r#""perm-id","550e8400-e29b-41d4-a716-446655440000"#)); 1228 assert!(json.contains(r#""tool-name","Bash"#)); 1229 assert!(json.contains(r#""role","permission_request"#)); 1230 // Has session identity 1231 assert!(json.contains(r#""d","sess-perm-test"#)); 1232 // Has discoverability tags 1233 assert!(json.contains(r#""t","ai-conversation"#)); 1234 assert!(json.contains(r#""t","ai-permission"#)); 1235 // Content has tool info 1236 assert!(json.contains("rm -rf")); 1237 } 1238 1239 #[test] 1240 fn test_truncate_tool_input_small() { 1241 // Small input should pass through unchanged 1242 let input = serde_json::json!({"command": "ls -la"}); 1243 let result = truncate_tool_input(&input, 1000); 1244 assert_eq!(input, result); 1245 assert!(result.get("_truncated").is_none()); 1246 } 1247 1248 #[test] 1249 fn test_truncate_tool_input_large_edit() { 1250 // Large edit should be truncated 1251 let big_old = "x".repeat(30_000); 1252 let big_new = "y".repeat(30_000); 1253 let input = serde_json::json!({ 1254 "file_path": "/some/file.rs", 1255 "old_string": big_old, 1256 "new_string": big_new, 1257 }); 1258 let result = truncate_tool_input(&input, 40_000); 1259 1260 // Should fit within budget 1261 let serialized = serde_json::to_string(&result).unwrap(); 1262 assert!(serialized.len() <= 40_000, "got {} bytes", serialized.len()); 1263 1264 // Should be marked as truncated 1265 assert_eq!( 1266 result.get("_truncated").and_then(|v| v.as_bool()), 1267 Some(true) 1268 ); 1269 1270 // file_path should be preserved 1271 assert_eq!( 1272 result.get("file_path").and_then(|v| v.as_str()), 1273 Some("/some/file.rs") 1274 ); 1275 1276 // Truncated fields should end with the suffix 1277 let old = result.get("old_string").and_then(|v| v.as_str()).unwrap(); 1278 assert!(old.ends_with("... (truncated)")); 1279 } 1280 1281 #[test] 1282 fn test_truncate_tool_input_utf8_boundary() { 1283 // Multibyte chars should not be split 1284 let big = "\u{1F600}".repeat(10_000); // 4-byte emoji repeated 1285 let input = serde_json::json!({"content": big}); 1286 let result = truncate_tool_input(&input, 1000); 1287 1288 let content = result.get("content").and_then(|v| v.as_str()).unwrap(); 1289 // Should be valid UTF-8 (this would panic if not) 1290 assert!(content.ends_with("... (truncated)")); 1291 } 1292 1293 #[test] 1294 fn test_build_permission_response_event() { 1295 let perm_id = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); 1296 let request_note_id = [42u8; 32]; 1297 let sk = test_secret_key(); 1298 1299 // Test allow response 1300 let event = build_permission_response_event( 1301 &perm_id, 1302 &request_note_id, 1303 true, 1304 Some("looks safe"), 1305 "sess-perm-test", 1306 &sk, 1307 ) 1308 .unwrap(); 1309 1310 assert_eq!(event.kind, AI_CONVERSATION_KIND); 1311 1312 let json = &event.note_json; 1313 assert!(json.contains(r#""perm-id","550e8400-e29b-41d4-a716-446655440000"#)); 1314 assert!(json.contains(r#""role","permission_response"#)); 1315 assert!(json.contains(r#""d","sess-perm-test"#)); 1316 assert!(json.contains("allow")); 1317 assert!(json.contains("looks safe")); 1318 // Has e tag linking to request 1319 assert!(json.contains(r#""e""#)); 1320 } 1321 1322 #[test] 1323 fn test_permission_response_deny() { 1324 let perm_id = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); 1325 let request_note_id = [42u8; 32]; 1326 let sk = test_secret_key(); 1327 1328 let event = build_permission_response_event( 1329 &perm_id, 1330 &request_note_id, 1331 false, 1332 Some("too dangerous"), 1333 "sess-perm-test", 1334 &sk, 1335 ) 1336 .unwrap(); 1337 1338 let json = &event.note_json; 1339 assert!(json.contains("deny")); 1340 assert!(json.contains("too dangerous")); 1341 } 1342 1343 #[test] 1344 fn test_build_session_state_event() { 1345 let sk = test_secret_key(); 1346 1347 let event = build_session_state_event( 1348 "sess-state-test", 1349 "Fix the login bug", 1350 Some("My Custom Title"), 1351 "/tmp/project", 1352 "working", 1353 Some("needs_input"), 1354 "my-laptop", 1355 "/home/testuser", 1356 "claude", 1357 "plan", 1358 None, 1359 &sk, 1360 ) 1361 .unwrap(); 1362 1363 assert_eq!(event.kind, AI_SESSION_STATE_KIND); 1364 1365 let json = &event.note_json; 1366 // Kind 31988 (parameterized replaceable) 1367 assert!(json.contains("31988")); 1368 // Has d tag for replacement 1369 assert!(json.contains(r#""d","sess-state-test"#)); 1370 // Has discoverability tags 1371 assert!(json.contains(r#""t","ai-session-state"#)); 1372 assert!(json.contains(r#""t","ai-conversation"#)); 1373 // Content has state fields 1374 assert!(json.contains("Fix the login bug")); 1375 assert!(json.contains("working")); 1376 assert!(json.contains("/tmp/project")); 1377 assert!(json.contains(r#""hostname","my-laptop"#)); 1378 assert!(json.contains(r#""backend","claude"#)); 1379 assert!(json.contains(r#""permission-mode","plan"#)); 1380 } 1381 1382 #[test] 1383 fn test_wrap_pns() { 1384 let sk = test_secret_key(); 1385 let pns_keys = enostr::pns::derive_pns_keys(&sk); 1386 1387 let inner = r#"{"kind":1988,"content":"hello","tags":[],"created_at":0,"pubkey":"abc","id":"def","sig":"ghi"}"#; 1388 let wrapped = wrap_pns(inner, &pns_keys).unwrap(); 1389 1390 // Outer event should be kind 1080 1391 assert!(wrapped.contains("1080")); 1392 // Should NOT contain the plaintext inner content 1393 assert!(!wrapped.contains("hello")); 1394 // Should be valid JSON 1395 assert!(serde_json::from_str::<serde_json::Value>(&wrapped).is_ok()); 1396 } 1397 }