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:
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(¬e, "role");
+ if role != Some("permission_response") {
+ continue;
+ }
+
+ // Extract perm-id
+ let Some(perm_id_str) = session_events::get_tag_value(¬e, "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(¬e)
- .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(¬e)
- .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"));
}
}