notedeck

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

commit 308193e2794fb5b05317a0949637bd946c1c938f
parent e12773fa7974a2d37d8fedf6be8b5471a2296364
Author: William Casarin <jb55@jb55.com>
Date:   Sun, 25 Jan 2026 22:53:35 -0800

dave: add session handling with --continue for conversation context

- Add session_id parameter to AiBackend trait
- Use --continue flag for follow-up messages to maintain conversation context
- Send only latest user message on continuation (reduces prompt size)
- Add integration tests for session context maintenance

Current limitation: --continue resumes globally last conversation,
not per-session. Multiple UI sessions would interfere with each other.

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

Diffstat:
Mcrates/notedeck_dave/src/backend/claude.rs | 56++++++++++++++++++++++++++++++++++++++++++++++++--------
Mcrates/notedeck_dave/src/backend/openai.rs | 1+
Mcrates/notedeck_dave/src/backend/traits.rs | 1+
Mcrates/notedeck_dave/src/lib.rs | 3++-
Mcrates/notedeck_dave/tests/claude_integration.rs | 226+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 278 insertions(+), 9 deletions(-)

diff --git a/crates/notedeck_dave/src/backend/claude.rs b/crates/notedeck_dave/src/backend/claude.rs @@ -61,6 +61,18 @@ impl ClaudeBackend { prompt } + + /// Extract only the latest user message for session continuation + fn get_latest_user_message(messages: &[Message]) -> String { + messages + .iter() + .rev() + .find_map(|m| match m { + Message::User(content) => Some(content.clone()), + _ => None, + }) + .unwrap_or_default() + } } impl AiBackend for ClaudeBackend { @@ -70,6 +82,7 @@ impl AiBackend for ClaudeBackend { _tools: Arc<HashMap<String, Tool>>, _model: String, _user_id: String, + _session_id: String, // TODO: Currently unused - --continue resumes last conversation globally ctx: egui::Context, ) -> mpsc::Receiver<DaveApiResponse> { let (tx, rx) = mpsc::channel(); @@ -78,11 +91,28 @@ impl AiBackend for ClaudeBackend { let tx_for_callback = tx.clone(); let ctx_for_callback = ctx.clone(); + // First message in session = start fresh conversation + // Subsequent messages = use --continue to resume the last conversation + // NOTE: --continue resumes the globally last conversation, not per-session. + // This works for single-conversation use but multiple UI sessions would interfere. + // For proper per-session context, we'd need a persistent ClaudeClient connection. + let is_first_message = messages + .iter() + .filter(|m| matches!(m, Message::User(_))) + .count() + == 1; + tokio::spawn(async move { - let prompt = ClaudeBackend::messages_to_prompt(&messages); + // For first message, send full prompt; for continuation, just the latest message + let prompt = if is_first_message { + Self::messages_to_prompt(&messages) + } else { + Self::get_latest_user_message(&messages) + }; tracing::debug!( - "Sending request to Claude Code: prompt length: {}, preview: {:?}", + "Sending request to Claude Code: is_first={}, prompt length: {}, preview: {:?}", + is_first_message, prompt.len(), &prompt[..prompt.len().min(100)] ); @@ -164,14 +194,24 @@ impl AiBackend for ClaudeBackend { } }); - let options = ClaudeAgentOptions::builder() - .permission_mode(PermissionMode::Default) - .stderr_callback(Arc::new(stderr_callback)) - .can_use_tool(can_use_tool) - .build(); - // Use ClaudeClient instead of query_stream to enable control protocol // for can_use_tool callbacks + // For follow-up messages, use --continue to resume the last conversation + let stderr_callback = Arc::new(stderr_callback); + let options = if is_first_message { + ClaudeAgentOptions::builder() + .permission_mode(PermissionMode::Default) + .stderr_callback(stderr_callback) + .can_use_tool(can_use_tool) + .build() + } else { + ClaudeAgentOptions::builder() + .permission_mode(PermissionMode::Default) + .stderr_callback(stderr_callback) + .can_use_tool(can_use_tool) + .continue_conversation(true) + .build() + }; let mut client = ClaudeClient::new(options); if let Err(err) = client.connect().await { tracing::error!("Claude Code connection error: {}", err); diff --git a/crates/notedeck_dave/src/backend/openai.rs b/crates/notedeck_dave/src/backend/openai.rs @@ -31,6 +31,7 @@ impl AiBackend for OpenAiBackend { tools: Arc<HashMap<String, Tool>>, model: String, user_id: String, + _session_id: String, ctx: egui::Context, ) -> mpsc::Receiver<DaveApiResponse> { let (tx, rx) = mpsc::channel(); diff --git a/crates/notedeck_dave/src/backend/traits.rs b/crates/notedeck_dave/src/backend/traits.rs @@ -22,6 +22,7 @@ pub trait AiBackend: Send + Sync { tools: Arc<HashMap<String, Tool>>, model: String, user_id: String, + session_id: String, ctx: egui::Context, ) -> mpsc::Receiver<DaveApiResponse>; } diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -364,6 +364,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr }; let user_id = calculate_user_id(app_ctx.accounts.get_selected_account().keypair()); + let session_id = format!("dave-session-{}", session.id); let messages = session.chat.clone(); let tools = self.tools.clone(); let model_name = self.model_config.model().to_owned(); @@ -372,7 +373,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr // Use backend to stream request let rx = self .backend - .stream_request(messages, tools, model_name, user_id, ctx); + .stream_request(messages, tools, model_name, user_id, session_id, ctx); session.incoming_tokens = Some(rx); } } diff --git a/crates/notedeck_dave/tests/claude_integration.rs b/crates/notedeck_dave/tests/claude_integration.rs @@ -255,6 +255,232 @@ async fn test_can_use_tool_callback_invoked() { println!("can_use_tool callback was invoked {} time(s)", count); } +/// Test session management - sending multiple queries with session context maintained. +/// The ClaudeClient must be kept connected to maintain session context. +#[tokio::test] +#[ignore = "Requires Claude Code CLI to be installed and authenticated"] +async fn test_session_context_maintained() { + if !cli_available() { + println!("Skipping: Claude CLI not available"); + return; + } + + let stderr_callback = |_msg: String| {}; + + let options = ClaudeAgentOptions::builder() + .permission_mode(PermissionMode::BypassPermissions) + .max_turns(1) + .skip_version_check(true) + .stderr_callback(Arc::new(stderr_callback)) + .build(); + + let mut client = ClaudeClient::new(options); + client.connect().await.expect("Failed to connect"); + + // First query - tell Claude a secret + let session_id = "test-session-context"; + println!("Sending first query to session: {}", session_id); + client + .query_with_session( + "Remember this secret code: BANANA42. Just acknowledge.", + session_id, + ) + .await + .expect("Failed to send first query"); + + // Consume first response + let mut first_response = String::new(); + { + let mut stream = client.receive_response(); + while let Some(result) = stream.next().await { + if let Ok(ClaudeMessage::Assistant(msg)) = result { + for block in &msg.message.content { + if let ContentBlock::Text(TextBlock { text }) = block { + first_response.push_str(text); + } + } + } + } + } + println!("First response: {}", first_response); + + // Second query - ask about the secret (should remember within same session) + println!("Sending second query to same session"); + client + .query_with_session("What was the secret code I told you?", session_id) + .await + .expect("Failed to send second query"); + + // Check if second response mentions the secret + let mut second_response = String::new(); + { + let mut stream = client.receive_response(); + while let Some(result) = stream.next().await { + if let Ok(ClaudeMessage::Assistant(msg)) = result { + for block in &msg.message.content { + if let ContentBlock::Text(TextBlock { text }) = block { + second_response.push_str(text); + } + } + } + } + } + println!("Second response: {}", second_response); + + client.disconnect().await.expect("Failed to disconnect"); + + // The second response should contain the secret code if context is maintained + assert!( + second_response.to_uppercase().contains("BANANA42"), + "Claude should remember the secret code from the same session. Got: {}", + second_response + ); +} + +/// Test that different session IDs maintain separate contexts. +#[tokio::test] +#[ignore = "Requires Claude Code CLI to be installed and authenticated"] +async fn test_separate_sessions_have_separate_context() { + if !cli_available() { + println!("Skipping: Claude CLI not available"); + return; + } + + let stderr_callback = |_msg: String| {}; + + let options = ClaudeAgentOptions::builder() + .permission_mode(PermissionMode::BypassPermissions) + .max_turns(1) + .skip_version_check(true) + .stderr_callback(Arc::new(stderr_callback)) + .build(); + + let mut client = ClaudeClient::new(options); + client.connect().await.expect("Failed to connect"); + + // First session - tell a secret + println!("Session A: Setting secret"); + client + .query_with_session( + "Remember: The password is APPLE123. Just acknowledge.", + "session-A", + ) + .await + .expect("Failed to send to session A"); + + { + let mut stream = client.receive_response(); + while let Some(_) = stream.next().await {} + } + + // Different session - should NOT know the secret + println!("Session B: Asking about secret"); + client + .query_with_session( + "What password did I tell you? If you don't know, just say 'I don't know any password'.", + "session-B", + ) + .await + .expect("Failed to send to session B"); + + let mut response_b = String::new(); + { + let mut stream = client.receive_response(); + while let Some(result) = stream.next().await { + if let Ok(ClaudeMessage::Assistant(msg)) = result { + for block in &msg.message.content { + if let ContentBlock::Text(TextBlock { text }) = block { + response_b.push_str(text); + } + } + } + } + } + println!("Session B response: {}", response_b); + + client.disconnect().await.expect("Failed to disconnect"); + + // Session B should NOT know the password from Session A + assert!( + !response_b.to_uppercase().contains("APPLE123"), + "Session B should NOT know the password from Session A. Got: {}", + response_b + ); +} + +/// Test --continue flag for resuming the last conversation. +/// This tests the simpler approach of continuing the most recent conversation. +#[tokio::test] +#[ignore = "Requires Claude Code CLI to be installed and authenticated"] +async fn test_continue_conversation_flag() { + if !cli_available() { + println!("Skipping: Claude CLI not available"); + return; + } + + let stderr_callback = |_msg: String| {}; + + // First: Start a fresh conversation + let options1 = ClaudeAgentOptions::builder() + .permission_mode(PermissionMode::BypassPermissions) + .max_turns(1) + .skip_version_check(true) + .stderr_callback(Arc::new(stderr_callback)) + .build(); + + let mut stream1 = query_stream( + "Remember this code: ZEBRA999. Just acknowledge.".to_string(), + Some(options1), + ) + .await + .expect("First query failed"); + + let mut first_response = String::new(); + while let Some(result) = stream1.next().await { + if let Ok(ClaudeMessage::Assistant(msg)) = result { + for block in &msg.message.content { + if let ContentBlock::Text(TextBlock { text }) = block { + first_response.push_str(text); + } + } + } + } + println!("First response: {}", first_response); + + // Second: Use --continue to resume and ask about the code + let stderr_callback2 = |_msg: String| {}; + let options2 = ClaudeAgentOptions::builder() + .permission_mode(PermissionMode::BypassPermissions) + .max_turns(1) + .skip_version_check(true) + .stderr_callback(Arc::new(stderr_callback2)) + .continue_conversation(true) + .build(); + + let mut stream2 = query_stream("What was the code I told you?".to_string(), Some(options2)) + .await + .expect("Second query failed"); + + let mut second_response = String::new(); + while let Some(result) = stream2.next().await { + if let Ok(ClaudeMessage::Assistant(msg)) = result { + for block in &msg.message.content { + if let ContentBlock::Text(TextBlock { text }) = block { + second_response.push_str(text); + } + } + } + } + println!("Second response (with --continue): {}", second_response); + + // Claude should remember the code when using --continue + assert!( + second_response.to_uppercase().contains("ZEBRA999"), + "Claude should remember the code with --continue. Got: {}", + second_response + ); +} + /// Test that denying a tool permission prevents the tool from executing. #[tokio::test] #[ignore = "Requires Claude Code CLI to be installed and authenticated"]