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:
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"]