commit 93a07519d0c371d514492134bd89d44496aa3df2
parent 684241f62a02cf36480fadbd5674b88403506f16
Author: William Casarin <jb55@jb55.com>
Date: Tue, 24 Feb 2026 18:28:00 -0800
dave: extract shared backend logic into backend/shared.rs
messages_to_prompt, get_pending_user_messages, prepare_prompt,
SessionCommand, and SessionHandle were duplicated across claude.rs
and codex.rs. Move them to a shared module so both backends import
from one place.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat:
5 files changed, 139 insertions(+), 207 deletions(-)
diff --git a/crates/notedeck_dave/src/backend/claude.rs b/crates/notedeck_dave/src/backend/claude.rs
@@ -1,5 +1,6 @@
use crate::auto_accept::AutoAcceptRules;
use crate::backend::session_info::parse_session_info;
+use crate::backend::shared::{self, SessionCommand, SessionHandle};
use crate::backend::tool_summary::{
extract_response_content, format_tool_summary, truncate_output,
};
@@ -36,30 +37,6 @@ fn tool_result_content_to_value(content: &Option<ToolResultContent>) -> serde_js
}
}
-/// Commands sent to a session's actor task
-enum SessionCommand {
- Query {
- prompt: String,
- response_tx: mpsc::Sender<DaveApiResponse>,
- ctx: egui::Context,
- },
- /// Interrupt the current query - stops the stream but preserves session
- Interrupt {
- ctx: egui::Context,
- },
- /// Set the permission mode (Default or Plan)
- SetPermissionMode {
- mode: PermissionMode,
- ctx: egui::Context,
- },
- Shutdown,
-}
-
-/// Handle to a session's actor
-struct SessionHandle {
- command_tx: tokio_mpsc::Sender<SessionCommand>,
-}
-
pub struct ClaudeBackend {
/// Registry of active sessions (using dashmap for lock-free access)
sessions: DashMap<String, SessionHandle>,
@@ -71,64 +48,6 @@ impl ClaudeBackend {
sessions: DashMap::new(),
}
}
-
- /// Convert our messages to a prompt for Claude Code
- fn messages_to_prompt(messages: &[Message]) -> String {
- let mut prompt = String::new();
-
- // Include system message if present
- for msg in messages {
- if let Message::System(content) = msg {
- prompt.push_str(content);
- prompt.push_str("\n\n");
- break;
- }
- }
-
- // Format conversation history
- for msg in messages {
- match msg {
- Message::System(_) => {} // Already handled
- Message::User(content) => {
- prompt.push_str("Human: ");
- prompt.push_str(content);
- prompt.push_str("\n\n");
- }
- Message::Assistant(content) => {
- prompt.push_str("Assistant: ");
- prompt.push_str(content.text());
- prompt.push_str("\n\n");
- }
- Message::ToolCalls(_)
- | Message::ToolResponse(_)
- | Message::Error(_)
- | Message::PermissionRequest(_)
- | Message::CompactionComplete(_)
- | Message::Subagent(_) => {
- // Skip tool-related, error, permission, compaction, and subagent messages
- }
- }
- }
-
- prompt
- }
-
- /// Collect all trailing user messages and join them.
- /// When multiple messages are queued, they're all sent as one prompt
- /// so the AI sees everything at once instead of one at a time.
- pub fn get_pending_user_messages(messages: &[Message]) -> String {
- let mut trailing: Vec<&str> = messages
- .iter()
- .rev()
- .take_while(|m| matches!(m, Message::User(_)))
- .filter_map(|m| match m {
- Message::User(content) => Some(content.as_str()),
- _ => None,
- })
- .collect();
- trailing.reverse();
- trailing.join("\n")
- }
}
/// Permission request forwarded from the callback to the actor
@@ -652,23 +571,7 @@ impl AiBackend for ClaudeBackend {
) {
let (response_tx, response_rx) = mpsc::channel();
- // For resumed sessions, always send just the latest message since
- // Claude Code already has the full conversation context via --resume.
- // For new sessions, send full prompt on the first message.
- let prompt = if resume_session_id.is_some() {
- Self::get_pending_user_messages(&messages)
- } else {
- let is_first_message = messages
- .iter()
- .filter(|m| matches!(m, Message::User(_)))
- .count()
- == 1;
- if is_first_message {
- Self::messages_to_prompt(&messages)
- } else {
- Self::get_pending_user_messages(&messages)
- }
- };
+ let prompt = shared::prepare_prompt(&messages, &resume_session_id);
tracing::debug!(
"Sending request to Claude Code: session={}, resumed={}, prompt length: {}, preview: {:?}",
@@ -769,7 +672,7 @@ mod tests {
#[test]
fn pending_messages_single_user() {
let messages = vec![Message::User("hello".into())];
- assert_eq!(ClaudeBackend::get_pending_user_messages(&messages), "hello");
+ assert_eq!(shared::get_pending_user_messages(&messages), "hello");
}
#[test]
@@ -782,7 +685,7 @@ mod tests {
Message::User("fourth".into()),
];
assert_eq!(
- ClaudeBackend::get_pending_user_messages(&messages),
+ shared::get_pending_user_messages(&messages),
"second\nthird\nfourth"
);
}
@@ -795,10 +698,7 @@ mod tests {
Message::Assistant(AssistantMessage::from_text("reply".into())),
Message::User("pending".into()),
];
- assert_eq!(
- ClaudeBackend::get_pending_user_messages(&messages),
- "pending"
- );
+ assert_eq!(shared::get_pending_user_messages(&messages), "pending");
}
#[test]
@@ -807,13 +707,13 @@ mod tests {
Message::User("hello".into()),
Message::Assistant(AssistantMessage::from_text("reply".into())),
];
- assert_eq!(ClaudeBackend::get_pending_user_messages(&messages), "");
+ assert_eq!(shared::get_pending_user_messages(&messages), "");
}
#[test]
fn pending_messages_empty_chat() {
let messages: Vec<Message> = vec![];
- assert_eq!(ClaudeBackend::get_pending_user_messages(&messages), "");
+ assert_eq!(shared::get_pending_user_messages(&messages), "");
}
#[test]
@@ -835,7 +735,7 @@ mod tests {
Message::User("queued 2".into()),
];
assert_eq!(
- ClaudeBackend::get_pending_user_messages(&messages),
+ shared::get_pending_user_messages(&messages),
"queued 1\nqueued 2"
);
}
@@ -847,9 +747,6 @@ mod tests {
Message::User("b".into()),
Message::User("c".into()),
];
- assert_eq!(
- ClaudeBackend::get_pending_user_messages(&messages),
- "a\nb\nc"
- );
+ assert_eq!(shared::get_pending_user_messages(&messages), "a\nb\nc");
}
}
diff --git a/crates/notedeck_dave/src/backend/codex.rs b/crates/notedeck_dave/src/backend/codex.rs
@@ -2,6 +2,7 @@
//! via its JSON-RPC-over-stdio protocol.
use super::codex_protocol::*;
+use super::shared::{self, SessionCommand, SessionHandle};
use super::tool_summary::{format_tool_summary, truncate_output};
use crate::auto_accept::AutoAcceptRules;
use crate::backend::traits::AiBackend;
@@ -29,28 +30,6 @@ use uuid::Uuid;
// Session actor
// ---------------------------------------------------------------------------
-/// Commands sent to a Codex session actor.
-enum SessionCommand {
- Query {
- prompt: String,
- response_tx: mpsc::Sender<DaveApiResponse>,
- ctx: egui::Context,
- },
- Interrupt {
- ctx: egui::Context,
- },
- SetPermissionMode {
- mode: PermissionMode,
- ctx: egui::Context,
- },
- Shutdown,
-}
-
-/// Handle kept by the backend to communicate with the actor.
-struct SessionHandle {
- command_tx: tokio_mpsc::Sender<SessionCommand>,
-}
-
/// Result of processing a single Codex JSON-RPC message.
enum HandleResult {
/// Normal notification processed, keep reading.
@@ -1151,50 +1130,6 @@ impl CodexBackend {
sessions: DashMap::new(),
}
}
-
- /// Convert messages to a prompt string, same logic as the Claude backend.
- fn messages_to_prompt(messages: &[Message]) -> String {
- let mut prompt = String::new();
- for msg in messages {
- if let Message::System(content) = msg {
- prompt.push_str(content);
- prompt.push_str("\n\n");
- break;
- }
- }
- for msg in messages {
- match msg {
- Message::System(_) => {}
- Message::User(content) => {
- prompt.push_str("Human: ");
- prompt.push_str(content);
- prompt.push_str("\n\n");
- }
- Message::Assistant(content) => {
- prompt.push_str("Assistant: ");
- prompt.push_str(content.text());
- prompt.push_str("\n\n");
- }
- _ => {}
- }
- }
- prompt
- }
-
- /// Collect all trailing user messages and join them.
- fn get_pending_user_messages(messages: &[Message]) -> String {
- let mut trailing: Vec<&str> = messages
- .iter()
- .rev()
- .take_while(|m| matches!(m, Message::User(_)))
- .filter_map(|m| match m {
- Message::User(content) => Some(content.as_str()),
- _ => None,
- })
- .collect();
- trailing.reverse();
- trailing.join("\n")
- }
}
impl AiBackend for CodexBackend {
@@ -1214,20 +1149,7 @@ impl AiBackend for CodexBackend {
) {
let (response_tx, response_rx) = mpsc::channel();
- let prompt = if resume_session_id.is_some() {
- Self::get_pending_user_messages(&messages)
- } else {
- let is_first_message = messages
- .iter()
- .filter(|m| matches!(m, Message::User(_)))
- .count()
- == 1;
- if is_first_message {
- Self::messages_to_prompt(&messages)
- } else {
- Self::get_pending_user_messages(&messages)
- }
- };
+ let prompt = shared::prepare_prompt(&messages, &resume_session_id);
tracing::debug!(
"Codex request: session={}, resumed={}, prompt_len={}",
@@ -1972,7 +1894,7 @@ mod tests {
#[test]
fn pending_messages_single_user() {
let messages = vec![Message::User("hello".into())];
- assert_eq!(CodexBackend::get_pending_user_messages(&messages), "hello");
+ assert_eq!(shared::get_pending_user_messages(&messages), "hello");
}
#[test]
@@ -1985,7 +1907,7 @@ mod tests {
Message::User("fourth".into()),
];
assert_eq!(
- CodexBackend::get_pending_user_messages(&messages),
+ shared::get_pending_user_messages(&messages),
"second\nthird\nfourth"
);
}
@@ -1998,10 +1920,7 @@ mod tests {
Message::Assistant(AssistantMessage::from_text("reply".into())),
Message::User("pending".into()),
];
- assert_eq!(
- CodexBackend::get_pending_user_messages(&messages),
- "pending"
- );
+ assert_eq!(shared::get_pending_user_messages(&messages), "pending");
}
#[test]
@@ -2010,13 +1929,13 @@ mod tests {
Message::User("hello".into()),
Message::Assistant(AssistantMessage::from_text("reply".into())),
];
- assert_eq!(CodexBackend::get_pending_user_messages(&messages), "");
+ assert_eq!(shared::get_pending_user_messages(&messages), "");
}
#[test]
fn pending_messages_empty_chat() {
let messages: Vec<Message> = vec![];
- assert_eq!(CodexBackend::get_pending_user_messages(&messages), "");
+ assert_eq!(shared::get_pending_user_messages(&messages), "");
}
#[test]
@@ -2038,7 +1957,7 @@ mod tests {
Message::User("queued 2".into()),
];
assert_eq!(
- CodexBackend::get_pending_user_messages(&messages),
+ shared::get_pending_user_messages(&messages),
"queued 1\nqueued 2"
);
}
@@ -2050,10 +1969,7 @@ mod tests {
Message::User("b".into()),
Message::User("c".into()),
];
- assert_eq!(
- CodexBackend::get_pending_user_messages(&messages),
- "a\nb\nc"
- );
+ assert_eq!(shared::get_pending_user_messages(&messages), "a\nb\nc");
}
// -----------------------------------------------------------------------
diff --git a/crates/notedeck_dave/src/backend/mod.rs b/crates/notedeck_dave/src/backend/mod.rs
@@ -4,6 +4,7 @@ mod codex_protocol;
mod openai;
mod remote;
mod session_info;
+pub(crate) mod shared;
mod tool_summary;
mod traits;
diff --git a/crates/notedeck_dave/src/backend/shared.rs b/crates/notedeck_dave/src/backend/shared.rs
@@ -0,0 +1,118 @@
+//! Shared utilities used by multiple AI backend implementations.
+
+use crate::messages::DaveApiResponse;
+use crate::Message;
+use claude_agent_sdk_rs::PermissionMode;
+use std::sync::mpsc;
+use tokio::sync::mpsc as tokio_mpsc;
+
+/// Commands sent to a session's actor task.
+///
+/// Used identically by the Claude and Codex backends.
+pub(crate) enum SessionCommand {
+ Query {
+ prompt: String,
+ response_tx: mpsc::Sender<DaveApiResponse>,
+ ctx: egui::Context,
+ },
+ /// Interrupt the current query - stops the stream but preserves session
+ Interrupt {
+ ctx: egui::Context,
+ },
+ /// Set the permission mode (Default or Plan)
+ SetPermissionMode {
+ mode: PermissionMode,
+ ctx: egui::Context,
+ },
+ Shutdown,
+}
+
+/// Handle kept by a backend to communicate with its session actor.
+pub(crate) struct SessionHandle {
+ pub command_tx: tokio_mpsc::Sender<SessionCommand>,
+}
+
+/// Convert our messages to a prompt string for the AI backend.
+///
+/// Includes the system message (if any) followed by the conversation
+/// history formatted as `Human:` / `Assistant:` turns. Tool-related,
+/// error, permission, compaction and subagent messages are skipped.
+pub fn messages_to_prompt(messages: &[Message]) -> String {
+ let mut prompt = String::new();
+
+ // Include system message if present
+ for msg in messages {
+ if let Message::System(content) = msg {
+ prompt.push_str(content);
+ prompt.push_str("\n\n");
+ break;
+ }
+ }
+
+ // Format conversation history
+ for msg in messages {
+ match msg {
+ Message::System(_) => {} // Already handled
+ Message::User(content) => {
+ prompt.push_str("Human: ");
+ prompt.push_str(content);
+ prompt.push_str("\n\n");
+ }
+ Message::Assistant(content) => {
+ prompt.push_str("Assistant: ");
+ prompt.push_str(content.text());
+ prompt.push_str("\n\n");
+ }
+ Message::ToolCalls(_)
+ | Message::ToolResponse(_)
+ | Message::Error(_)
+ | Message::PermissionRequest(_)
+ | Message::CompactionComplete(_)
+ | Message::Subagent(_) => {}
+ }
+ }
+
+ prompt
+}
+
+/// Collect all trailing user messages and join them.
+///
+/// When multiple messages are queued, they're all sent as one prompt
+/// so the AI sees everything at once instead of one at a time.
+pub fn get_pending_user_messages(messages: &[Message]) -> String {
+ let mut trailing: Vec<&str> = messages
+ .iter()
+ .rev()
+ .take_while(|m| matches!(m, Message::User(_)))
+ .filter_map(|m| match m {
+ Message::User(content) => Some(content.as_str()),
+ _ => None,
+ })
+ .collect();
+ trailing.reverse();
+ trailing.join("\n")
+}
+
+/// Decide which prompt to send based on whether we're resuming a
+/// session and how many user messages exist.
+///
+/// - Resumed sessions always send just the pending messages (the
+/// backend already has the full conversation context).
+/// - New sessions send the full prompt on the first message, then
+/// only pending messages for subsequent turns.
+pub fn prepare_prompt(messages: &[Message], resume_session_id: &Option<String>) -> String {
+ if resume_session_id.is_some() {
+ get_pending_user_messages(messages)
+ } else {
+ let is_first_message = messages
+ .iter()
+ .filter(|m| matches!(m, Message::User(_)))
+ .count()
+ == 1;
+ if is_first_message {
+ messages_to_prompt(messages)
+ } else {
+ get_pending_user_messages(messages)
+ }
+ }
+}
diff --git a/crates/notedeck_dave/src/session.rs b/crates/notedeck_dave/src/session.rs
@@ -1363,7 +1363,7 @@ mod tests {
#[test]
fn batch_redispatch_full_lifecycle() {
let mut session = test_session();
- use crate::backend::claude::ClaudeBackend;
+ use crate::backend::shared;
// Step 1: User sends first message, it gets dispatched (single)
session.chat.push(Message::User("hello".into()));
@@ -1411,7 +1411,7 @@ mod tests {
// Step 4: At redispatch time, get_pending_user_messages should
// collect ALL trailing user messages
- let prompt = ClaudeBackend::get_pending_user_messages(&session.chat);
+ let prompt = shared::get_pending_user_messages(&session.chat);
assert_eq!(prompt, "also\ndo this\nand this");
// Step 5: Backend dispatches with the batch prompt (3 messages)