notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit 0c8735608779ec60d6e63c0e9ce6b3cf2cbd200e
parent bb68dd10dda9405083b2d601b34e5505739c0dc7
Author: William Casarin <jb55@jb55.com>
Date:   Mon, 16 Feb 2026 15:26:36 -0800

session_events: permission events + relay publishing + remote response polling

- Refactor BuiltEvent to store note_json (bare event JSON), add to_event_json()
  for ndb ingestion. All build sites updated.
- ingest_live_event() returns Option<BuiltEvent> for relay publishing
- process_events() returns (sessions, events) tuple, update() publishes to pool
- pending_relay_events queue for events from non-pool contexts (handle_user_send)
- build_permission_request_event(): kind-1988 with perm-id, tool-name, t:ai-permission
- build_permission_response_event(): kind-1988 response with e-tag linking to request
- Subscribe for remote permission responses when claude_session_id is learned
- poll_remote_permission_responses(): routes relay events through existing oneshot
  channels, first-response-wins with local UI
- AgenticSessionData: add perm_request_note_ids, perm_response_sub fields

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Diffstat:
Mcrates/notedeck_dave/src/lib.rs | 264++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mcrates/notedeck_dave/src/session.rs | 8++++++++
Mcrates/notedeck_dave/src/session_converter.rs | 2+-
Mcrates/notedeck_dave/src/session_events.rs | 333+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
4 files changed, 527 insertions(+), 80 deletions(-)

diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -111,6 +111,8 @@ pub struct Dave { pending_archive_convert: Option<(std::path::PathBuf, SessionId, String)>, /// Waiting for ndb to finish indexing 1988 events so we can load messages. pending_message_load: Option<PendingMessageLoad>, + /// Events waiting to be published to relays (queued from non-pool contexts). + pending_relay_events: Vec<session_events::BuiltEvent>, } /// Subscription waiting for ndb to index 1988 conversation events. @@ -126,7 +128,7 @@ struct PendingMessageLoad { /// Build and ingest a live kind-1988 event into ndb. /// /// Extracts cwd and session ID from the session's agentic data, -/// builds the event, and ingests it. Logs errors without propagating. +/// builds the event, ingests it, and returns the event for relay publishing. fn ingest_live_event( session: &mut ChatSession, ndb: &nostrdb::Ndb, @@ -134,15 +136,9 @@ fn ingest_live_event( content: &str, role: &str, tool_id: Option<&str>, -) { - let Some(agentic) = &mut session.agentic else { - return; - }; - - let Some(session_id) = agentic.event_session_id().map(|s| s.to_string()) else { - return; - }; - +) -> Option<session_events::BuiltEvent> { + let agentic = session.agentic.as_mut()?; + let session_id = agentic.event_session_id().map(|s| s.to_string())?; let cwd = agentic.cwd.to_str(); match session_events::build_live_event( @@ -155,14 +151,17 @@ fn ingest_live_event( secret_key, ) { Ok(event) => { - if let Err(e) = ndb - .process_event_with(&event.json, nostrdb::IngestMetadata::new().client(true)) - { + if let Err(e) = ndb.process_event_with( + &event.to_event_json(), + nostrdb::IngestMetadata::new().client(true), + ) { tracing::warn!("failed to ingest live event: {:?}", e); } + Some(event) } Err(e) => { tracing::warn!("failed to build live event: {}", e); + None } } } @@ -279,6 +278,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr ipc_listener, pending_archive_convert: None, pending_message_load: None, + pending_relay_events: Vec::new(), } } @@ -293,11 +293,15 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr self.settings = settings; } - /// Process incoming tokens from the ai backend for ALL sessions - /// Returns a set of session IDs that need to send tool responses - fn process_events(&mut self, app_ctx: &AppContext) -> HashSet<SessionId> { + /// Process incoming tokens from the ai backend for ALL sessions. + /// Returns (sessions needing tool responses, events to publish to relays). + fn process_events( + &mut self, + app_ctx: &AppContext, + ) -> (HashSet<SessionId>, Vec<session_events::BuiltEvent>) { // Track which sessions need to send tool responses let mut needs_send: HashSet<SessionId> = HashSet::new(); + let mut events_to_publish: Vec<session_events::BuiltEvent> = Vec::new(); let active_id = self.session_manager.active_id(); // Extract secret key once for live event generation @@ -343,7 +347,9 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr match res { DaveApiResponse::Failed(ref err) => { if let Some(sk) = &secret_key { - ingest_live_event(session, app_ctx.ndb, sk, err, "error", None); + if let Some(evt) = ingest_live_event(session, app_ctx.ndb, sk, err, "error", None) { + events_to_publish.push(evt); + } } session.chat.push(Message::Error(err.to_string())); } @@ -404,20 +410,44 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr pending.request.tool_input ); - // Generate live event for permission request + // Build and publish a proper permission request event + // with perm-id, tool-name tags for remote clients if let Some(sk) = &secret_key { - let content = format!( - "Permission request: {}", - pending.request.tool_name - ); - ingest_live_event( - session, - app_ctx.ndb, - sk, - &content, - "permission_request", - None, - ); + let event_session_id = session + .agentic + .as_ref() + .and_then(|a| a.event_session_id().map(|s| s.to_string())); + + if let Some(sid) = event_session_id { + match session_events::build_permission_request_event( + &pending.request.id, + &pending.request.tool_name, + &pending.request.tool_input, + &sid, + sk, + ) { + Ok(evt) => { + // Ingest into local ndb + if let Err(e) = app_ctx.ndb.process_event_with( + &evt.to_event_json(), + nostrdb::IngestMetadata::new().client(true), + ) { + tracing::warn!("failed to ingest permission request: {:?}", e); + } + // Store note_id for linking responses + if let Some(agentic) = &mut session.agentic { + agentic.perm_request_note_ids.insert( + pending.request.id, + evt.note_id, + ); + } + events_to_publish.push(evt); + } + Err(e) => { + tracing::warn!("failed to build permission request event: {}", e); + } + } + } } // Store the response sender for later (agentic only) @@ -440,14 +470,16 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr if let Some(sk) = &secret_key { let content = format!("{}: {}", result.tool_name, result.summary); - ingest_live_event( + if let Some(evt) = ingest_live_event( session, app_ctx.ndb, sk, &content, "tool_result", None, - ); + ) { + events_to_publish.push(evt); + } } // Invalidate git status after file-modifying tools. @@ -467,7 +499,33 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr info.tools.len(), info.agents.len() ); + // Set up permission response subscription when we learn + // the claude session ID (used as `d` tag for filtering) if let Some(agentic) = &mut session.agentic { + if agentic.perm_response_sub.is_none() { + if let Some(ref csid) = info.claude_session_id { + let filter = nostrdb::Filter::new() + .kinds([session_events::AI_CONVERSATION_KIND as u64]) + .tags([csid.as_str()], 'd') + .tags(["ai-permission"], 't') + .build(); + match app_ctx.ndb.subscribe(&[filter]) { + Ok(sub) => { + tracing::info!( + "subscribed for remote permission responses (session {})", + csid + ); + agentic.perm_response_sub = Some(sub); + } + Err(e) => { + tracing::warn!( + "failed to subscribe for permission responses: {:?}", + e + ); + } + } + } + } agentic.session_info = Some(info); } } @@ -533,14 +591,16 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr if let Some(Message::Assistant(msg)) = session.chat.last() { let text = msg.text().to_string(); if !text.is_empty() { - ingest_live_event( + if let Some(evt) = ingest_live_event( session, app_ctx.ndb, sk, &text, "assistant", None, - ); + ) { + events_to_publish.push(evt); + } } } } @@ -558,7 +618,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr } } - needs_send + (needs_send, events_to_publish) } fn ui(&mut self, app_ctx: &mut AppContext, ui: &mut egui::Ui) -> DaveResponse { @@ -831,6 +891,119 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr } } + /// Poll for remote permission responses arriving via nostr relays. + /// + /// Remote clients (phone) publish kind-1988 events with + /// `role=permission_response` and a `perm-id` tag. We poll each + /// session's subscription and route matching responses through the + /// existing oneshot channel, racing with the local UI. + fn poll_remote_permission_responses(&mut self, ndb: &nostrdb::Ndb) { + let session_ids = self.session_manager.session_ids(); + for session_id in session_ids { + let Some(session) = self.session_manager.get_mut(session_id) else { + continue; + }; + let Some(agentic) = &mut session.agentic else { + continue; + }; + let Some(sub) = agentic.perm_response_sub else { + continue; + }; + + // Poll for new notes (non-blocking) + let note_keys = ndb.poll_for_notes(sub, 64); + if note_keys.is_empty() { + continue; + } + + let txn = match Transaction::new(ndb) { + Ok(txn) => txn, + Err(_) => continue, + }; + + for key in note_keys { + let Ok(note) = ndb.get_note_by_key(&txn, key) else { + continue; + }; + + // Only process permission_response events + let role = session_events::get_tag_value(&note, "role"); + if role != Some("permission_response") { + continue; + } + + // Extract perm-id + let Some(perm_id_str) = session_events::get_tag_value(&note, "perm-id") else { + tracing::warn!("permission_response event missing perm-id tag"); + continue; + }; + let Ok(perm_id) = uuid::Uuid::parse_str(perm_id_str) else { + tracing::warn!("invalid perm-id UUID: {}", perm_id_str); + continue; + }; + + // Parse the content to determine allow/deny + let content = note.content(); + let (allowed, message) = match serde_json::from_str::<serde_json::Value>(content) { + Ok(v) => { + let decision = v + .get("decision") + .and_then(|d| d.as_str()) + .unwrap_or("deny"); + let msg = v + .get("message") + .and_then(|m| m.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + (decision == "allow", msg) + } + Err(_) => (false, None), + }; + + // Route through the existing oneshot channel (first-response-wins) + if let Some(sender) = agentic.pending_permissions.remove(&perm_id) { + let response = if allowed { + PermissionResponse::Allow { message } + } else { + PermissionResponse::Deny { + reason: message.unwrap_or_else(|| "Denied by remote".to_string()), + } + }; + + // Mark in UI + let response_type = if allowed { + crate::messages::PermissionResponseType::Allowed + } else { + crate::messages::PermissionResponseType::Denied + }; + for msg in &mut session.chat { + if let Message::PermissionRequest(req) = msg { + if req.id == perm_id { + req.response = Some(response_type); + break; + } + } + } + + if sender.send(response).is_err() { + tracing::warn!( + "failed to send remote permission response for {}", + perm_id + ); + } else { + tracing::info!( + "remote permission response for {}: {}", + perm_id, + if allowed { "allowed" } else { "denied" } + ); + } + } + // If sender not found, either local UI already responded or + // this is a stale event — just ignore it silently. + } + } + } + /// Delete a session and clean up backend resources fn delete_session(&mut self, id: SessionId) { update::delete_session( @@ -971,7 +1144,9 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr { let sb = sk.as_secret_bytes(); let secret_bytes: [u8; 32] = sb.try_into().expect("secret key is 32 bytes"); - ingest_live_event(session, app_ctx.ndb, &secret_bytes, &user_text, "user", None); + if let Some(evt) = ingest_live_event(session, app_ctx.ndb, &secret_bytes, &user_text, "user", None) { + self.pending_relay_events.push(evt); + } } session.chat.push(Message::User(user_text)); @@ -1172,7 +1347,22 @@ impl notedeck::App for Dave { self.check_interrupt_timeout(); // Process incoming AI responses for all sessions - let sessions_needing_send = self.process_events(ctx); + let (sessions_needing_send, events_to_publish) = self.process_events(ctx); + + // Publish events to relay pool for remote session control. + // Includes events from process_events() and any queued from handle_user_send(). + let pending = std::mem::take(&mut self.pending_relay_events); + for event in events_to_publish.iter().chain(pending.iter()) { + match enostr::ClientMessage::event_json(event.note_json.clone()) { + Ok(msg) => ctx.pool.send(&msg), + Err(e) => tracing::warn!("failed to build relay message: {:?}", e), + } + } + + // Poll for remote permission responses from relay events. + // These arrive as kind-1988 events with role=permission_response, + // published by phone/remote clients. First-response-wins with local UI. + self.poll_remote_permission_responses(ctx.ndb); // Poll git status for all agentic sessions for session in self.session_manager.iter_mut() { diff --git a/crates/notedeck_dave/src/session.rs b/crates/notedeck_dave/src/session.rs @@ -58,6 +58,12 @@ pub struct AgenticSessionData { pub git_status: GitStatusCache, /// Threading state for live kind-1988 event generation. pub live_threading: ThreadingState, + /// Maps permission request UUID → note ID of the published request event. + /// Used to link permission response events back to their requests. + pub perm_request_note_ids: HashMap<Uuid, [u8; 32]>, + /// Subscription for remote permission response events (kind-1988, t=ai-permission). + /// Set up once when the session's claude_session_id becomes known. + pub perm_response_sub: Option<nostrdb::Subscription>, } impl AgenticSessionData { @@ -85,6 +91,8 @@ impl AgenticSessionData { resume_session_id: None, git_status, live_threading: ThreadingState::new(), + perm_request_note_ids: HashMap::new(), + perm_response_sub: None, } } diff --git a/crates/notedeck_dave/src/session_converter.rs b/crates/notedeck_dave/src/session_converter.rs @@ -47,7 +47,7 @@ pub fn convert_session_to_events( /// Ingest a single built event into the local ndb. fn ingest_event(ndb: &Ndb, event: &BuiltEvent) -> Result<(), ConvertError> { - ndb.process_event_with(&event.json, IngestMetadata::new().client(true)) + ndb.process_event_with(&event.to_event_json(), IngestMetadata::new().client(true)) .map_err(|e| ConvertError::Ingest(format!("{:?}", e)))?; Ok(()) } diff --git a/crates/notedeck_dave/src/session_events.rs b/crates/notedeck_dave/src/session_events.rs @@ -33,17 +33,24 @@ pub fn get_tag_value<'a>(note: &'a nostrdb::Note<'a>, tag_name: &str) -> Option< None } -/// A built nostr event ready for ingestion, with its note ID. +/// A built nostr event ready for ingestion and relay publishing. #[derive(Debug)] pub struct BuiltEvent { - /// The full JSON string: `["EVENT", {…}]` - pub json: String, + /// The bare event JSON `{…}` — for relay publishing and ndb ingestion. + pub note_json: String, /// The 32-byte note ID (from the signed event). pub note_id: [u8; 32], /// The nostr event kind (1988 or 1989). pub kind: u32, } +impl BuiltEvent { + /// Format as `["EVENT", {…}]` for ndb ingestion via `process_event_with`. + pub fn to_event_json(&self) -> String { + format!("[\"EVENT\", {}]", self.note_json) + } +} + /// Maintains threading state across a session's events. pub struct ThreadingState { /// Maps JSONL uuid → nostr note ID (32 bytes). @@ -317,15 +324,12 @@ fn build_source_data_event( let note_id: [u8; 32] = *note.id(); - let event = enostr::ClientMessage::event(&note) - .map_err(|e| EventBuildError::Serialize(format!("{:?}", e)))?; - - let json = event - .to_json() + let note_json = note + .json() .map_err(|e| EventBuildError::Serialize(format!("{:?}", e)))?; Ok(BuiltEvent { - json, + note_json, note_id, kind: AI_SOURCE_DATA_KIND, }) @@ -442,15 +446,12 @@ fn build_single_event( let note_id: [u8; 32] = *note.id(); - let event = enostr::ClientMessage::event(&note) - .map_err(|e| EventBuildError::Serialize(format!("{:?}", e)))?; - - let json = event - .to_json() + let note_json = note + .json() .map_err(|e| EventBuildError::Serialize(format!("{:?}", e)))?; Ok(BuiltEvent { - json, + note_json, note_id, kind: AI_CONVERSATION_KIND, }) @@ -494,6 +495,173 @@ pub fn build_live_event( Ok(event) } +/// Build a kind-1988 permission request event. +/// +/// Published to relays so remote clients (phone) can see pending permission +/// requests and respond. Tags include `perm-id` (UUID), `tool-name`, and +/// `t: ai-permission` for filtering. +/// +/// Does NOT participate in threading — permission events are ancillary. +pub fn build_permission_request_event( + perm_id: &uuid::Uuid, + tool_name: &str, + tool_input: &serde_json::Value, + session_id: &str, + secret_key: &[u8; 32], +) -> Result<BuiltEvent, EventBuildError> { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + // Content is a JSON summary for display on remote clients + let content = serde_json::json!({ + "tool_name": tool_name, + "tool_input": tool_input, + }) + .to_string(); + + let perm_id_str = perm_id.to_string(); + + let mut builder = NoteBuilder::new() + .kind(AI_CONVERSATION_KIND) + .content(&content) + .options(NoteBuildOptions::default()) + .created_at(now); + + // Session identity + builder = builder.start_tag().tag_str("d").tag_str(session_id); + + // Permission-specific tags + builder = builder + .start_tag() + .tag_str("perm-id") + .tag_str(&perm_id_str); + builder = builder + .start_tag() + .tag_str("tool-name") + .tag_str(tool_name); + builder = builder + .start_tag() + .tag_str("role") + .tag_str("permission_request"); + builder = builder + .start_tag() + .tag_str("source") + .tag_str("notedeck-dave"); + + // Discoverability + builder = builder + .start_tag() + .tag_str("t") + .tag_str("ai-conversation"); + builder = builder + .start_tag() + .tag_str("t") + .tag_str("ai-permission"); + + let note = builder + .sign(secret_key) + .build() + .ok_or_else(|| EventBuildError::Build("NoteBuilder::build returned None".to_string()))?; + + let note_id: [u8; 32] = *note.id(); + let note_json = note + .json() + .map_err(|e| EventBuildError::Serialize(format!("{:?}", e)))?; + + Ok(BuiltEvent { + note_json, + note_id, + kind: AI_CONVERSATION_KIND, + }) +} + +/// Build a kind-1988 permission response event. +/// +/// Published by remote clients (phone) to allow/deny a permission request. +/// The desktop subscribes for these and routes them through the existing +/// oneshot channel, racing with the local UI. +/// +/// Tags include `perm-id` (matching the request), `e` tag linking to the +/// request event, and `t: ai-permission` for filtering. +pub fn build_permission_response_event( + perm_id: &uuid::Uuid, + request_note_id: &[u8; 32], + allowed: bool, + message: Option<&str>, + session_id: &str, + secret_key: &[u8; 32], +) -> Result<BuiltEvent, EventBuildError> { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let content = serde_json::json!({ + "decision": if allowed { "allow" } else { "deny" }, + "message": message.unwrap_or(""), + }) + .to_string(); + + let perm_id_str = perm_id.to_string(); + + let mut builder = NoteBuilder::new() + .kind(AI_CONVERSATION_KIND) + .content(&content) + .options(NoteBuildOptions::default()) + .created_at(now); + + // Session identity + builder = builder.start_tag().tag_str("d").tag_str(session_id); + + // Link to the request event + builder = builder + .start_tag() + .tag_str("e") + .tag_id(request_note_id); + + // Permission-specific tags + builder = builder + .start_tag() + .tag_str("perm-id") + .tag_str(&perm_id_str); + builder = builder + .start_tag() + .tag_str("role") + .tag_str("permission_response"); + builder = builder + .start_tag() + .tag_str("source") + .tag_str("notedeck-dave"); + + // Discoverability + builder = builder + .start_tag() + .tag_str("t") + .tag_str("ai-conversation"); + builder = builder + .start_tag() + .tag_str("t") + .tag_str("ai-permission"); + + let note = builder + .sign(secret_key) + .build() + .ok_or_else(|| EventBuildError::Build("NoteBuilder::build returned None".to_string()))?; + + let note_id: [u8; 32] = *note.id(); + let note_json = note + .json() + .map_err(|e| EventBuildError::Serialize(format!("{:?}", e)))?; + + Ok(BuiltEvent { + note_json, + note_id, + kind: AI_CONVERSATION_KIND, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -523,7 +691,7 @@ mod tests { assert_eq!(threading.root_note_id, Some(events[0].note_id)); // 1988 event has kind and tags but NO source-data - let json = &events[0].json; + let json = &events[0].note_json; assert!(json.contains("1988")); assert!(json.contains("source")); assert!(json.contains("claude-code")); @@ -532,7 +700,7 @@ mod tests { assert!(!json.contains("source-data")); // 1989 event has source-data - assert!(events[1].json.contains("source-data")); + assert!(events[1].note_json.contains("source-data")); } #[test] @@ -553,7 +721,7 @@ mod tests { assert_eq!(events[0].kind, AI_CONVERSATION_KIND); assert_eq!(events[1].kind, AI_SOURCE_DATA_KIND); - let json = &events[0].json; + let json = &events[0].note_json; assert!(json.contains("assistant")); assert!(json.contains("claude-opus-4-5-20251101")); // model tag } @@ -609,11 +777,11 @@ mod tests { // First event should be root (no e tags) // Subsequent events should reference root + previous - assert!(!conv_events[0].json.contains("root")); - assert!(conv_events[1].json.contains("root")); - assert!(conv_events[1].json.contains("reply")); - assert!(conv_events[2].json.contains("root")); - assert!(conv_events[2].json.contains("reply")); + assert!(!conv_events[0].note_json.contains("root")); + assert!(conv_events[1].note_json.contains("root")); + assert!(conv_events[1].note_json.contains("reply")); + assert!(conv_events[2].note_json.contains("root")); + assert!(conv_events[2].note_json.contains("reply")); } #[test] @@ -627,12 +795,12 @@ mod tests { let events = build_events(&line, &mut threading, &test_secret_key()).unwrap(); // 1988 event should NOT have source-data - assert!(!events[0].json.contains("source-data")); + assert!(!events[0].note_json.contains("source-data")); // 1989 event should have source-data with raw paths preserved let sd_event = events.iter().find(|e| e.kind == AI_SOURCE_DATA_KIND).unwrap(); - assert!(sd_event.json.contains("source-data")); - assert!(sd_event.json.contains("/Users/jb55/dev/notedeck")); + assert!(sd_event.note_json.contains("source-data")); + assert!(sd_event.note_json.contains("/Users/jb55/dev/notedeck")); } #[test] @@ -647,7 +815,7 @@ mod tests { // 1 conversation (1988) + 1 source-data (1989) assert_eq!(events.len(), 2); - let json = &events[0].json; + let json = &events[0].note_json; assert!(json.contains("queue-operation")); } @@ -669,16 +837,16 @@ mod tests { assert_eq!(events.len(), 2); assert_eq!(threading.seq(), 1); // First 1988 event should have seq=0 - assert!(events[0].json.contains(r#""seq","0"#)); + assert!(events[0].note_json.contains(r#""seq","0"#)); // 1989 event should also have seq=0 (matches its 1988 event) - assert!(events[1].json.contains(r#""seq","0"#)); + assert!(events[1].note_json.contains(r#""seq","0"#)); let line = JsonlLine::parse(lines[1]).unwrap(); let events = build_events(&line, &mut threading, &sk).unwrap(); assert_eq!(events.len(), 2); assert_eq!(threading.seq(), 2); // Second 1988 event should have seq=1 - assert!(events[0].json.contains(r#""seq","1"#)); + assert!(events[0].note_json.contains(r#""seq","1"#)); } #[test] @@ -694,17 +862,17 @@ mod tests { assert_eq!(events.len(), 3); // First event (text): split 0/2, NO source-data (moved to 1989) - assert!(events[0].json.contains(r#""split","0/2"#)); - assert!(!events[0].json.contains("source-data")); + assert!(events[0].note_json.contains(r#""split","0/2"#)); + assert!(!events[0].note_json.contains("source-data")); // Second event (tool_call): split 1/2, NO source-data, has tool-id - assert!(events[1].json.contains(r#""split","1/2"#)); - assert!(!events[1].json.contains("source-data")); - assert!(events[1].json.contains(r#""tool-id","t1"#)); + assert!(events[1].note_json.contains(r#""split","1/2"#)); + assert!(!events[1].note_json.contains("source-data")); + assert!(events[1].note_json.contains(r#""tool-id","t1"#)); // Third event (1989): has source-data assert_eq!(events[2].kind, AI_SOURCE_DATA_KIND); - assert!(events[2].json.contains("source-data")); + assert!(events[2].note_json.contains("source-data")); } #[test] @@ -718,7 +886,7 @@ mod tests { let events = build_events(&line, &mut threading, &test_secret_key()).unwrap(); assert!(events[0] - .json + .note_json .contains(r#""cwd","/Users/jb55/dev/notedeck"#)); } @@ -733,7 +901,7 @@ mod tests { let events = build_events(&line, &mut threading, &test_secret_key()).unwrap(); // 1 conversation + 1 source-data assert_eq!(events.len(), 2); - assert!(events[0].json.contains(r#""tool-id","toolu_abc"#)); + assert!(events[0].note_json.contains(r#""tool-id","toolu_abc"#)); } #[tokio::test] @@ -770,7 +938,7 @@ mod tests { let events = build_events(&line, &mut threading, &sk).unwrap(); for event in &events { let sub_id = ndb.subscribe(&[filter.clone()]).unwrap(); - ndb.process_event_with(&event.json, IngestMetadata::new().client(true)) + ndb.process_event_with(&event.to_event_json(), IngestMetadata::new().client(true)) .expect("ingest failed"); let _keys = ndb.wait_for_notes(sub_id, 1).await.unwrap(); total_events += 1; @@ -830,7 +998,7 @@ mod tests { // First line sets context let line = JsonlLine::parse(lines[0]).unwrap(); let events = build_events(&line, &mut threading, &sk).unwrap(); - assert!(events[0].json.contains(r#""d","ctx-test"#)); + assert!(events[0].note_json.contains(r#""d","ctx-test"#)); // Second line (file-history-snapshot) should inherit session_id let line = JsonlLine::parse(lines[1]).unwrap(); @@ -838,8 +1006,89 @@ mod tests { let events = build_events(&line, &mut threading, &sk).unwrap(); // 1988 event should have inherited d tag - assert!(events[0].json.contains(r#""d","ctx-test"#)); + assert!(events[0].note_json.contains(r#""d","ctx-test"#)); // Should have snapshot timestamp (1770773371), not the user's - assert!(events[0].json.contains(r#""created_at":1770773371"#)); + assert!(events[0].note_json.contains(r#""created_at":1770773371"#)); + } + + #[test] + fn test_build_permission_request_event() { + let perm_id = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); + let tool_input = serde_json::json!({"command": "rm -rf /tmp/test"}); + let sk = test_secret_key(); + + let event = build_permission_request_event( + &perm_id, + "Bash", + &tool_input, + "sess-perm-test", + &sk, + ) + .unwrap(); + + assert_eq!(event.kind, AI_CONVERSATION_KIND); + + let json = &event.note_json; + // Has permission-specific tags + assert!(json.contains(r#""perm-id","550e8400-e29b-41d4-a716-446655440000"#)); + assert!(json.contains(r#""tool-name","Bash"#)); + assert!(json.contains(r#""role","permission_request"#)); + // Has session identity + assert!(json.contains(r#""d","sess-perm-test"#)); + // Has discoverability tags + assert!(json.contains(r#""t","ai-conversation"#)); + assert!(json.contains(r#""t","ai-permission"#)); + // Content has tool info + assert!(json.contains("rm -rf")); + } + + #[test] + fn test_build_permission_response_event() { + let perm_id = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); + let request_note_id = [42u8; 32]; + let sk = test_secret_key(); + + // Test allow response + let event = build_permission_response_event( + &perm_id, + &request_note_id, + true, + Some("looks safe"), + "sess-perm-test", + &sk, + ) + .unwrap(); + + assert_eq!(event.kind, AI_CONVERSATION_KIND); + + let json = &event.note_json; + assert!(json.contains(r#""perm-id","550e8400-e29b-41d4-a716-446655440000"#)); + assert!(json.contains(r#""role","permission_response"#)); + assert!(json.contains(r#""d","sess-perm-test"#)); + assert!(json.contains("allow")); + assert!(json.contains("looks safe")); + // Has e tag linking to request + assert!(json.contains(r#""e""#)); + } + + #[test] + fn test_permission_response_deny() { + let perm_id = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); + let request_note_id = [42u8; 32]; + let sk = test_secret_key(); + + let event = build_permission_response_event( + &perm_id, + &request_note_id, + false, + Some("too dangerous"), + "sess-perm-test", + &sk, + ) + .unwrap(); + + let json = &event.note_json; + assert!(json.contains("deny")); + assert!(json.contains("too dangerous")); } }