notedeck

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

messages.rs (14616B)


      1 use crate::tools::{ToolCall, ToolResponse, ToolResponses};
      2 use async_openai::types::*;
      3 use md_stream::{MdElement, Partial, StreamParser};
      4 
      5 /// Pre-parsed markdown with source text for span resolution.
      6 #[derive(Debug, Clone)]
      7 pub struct ParsedMarkdown {
      8     pub source: String,
      9     pub elements: Vec<MdElement>,
     10 }
     11 
     12 impl ParsedMarkdown {
     13     /// Parse a markdown string into elements.
     14     pub fn parse(text: &str) -> Self {
     15         let mut parser = StreamParser::new();
     16         parser.push(text);
     17         parser.finalize();
     18         let (elements, source) = parser.into_parts();
     19         Self { source, elements }
     20     }
     21 }
     22 use nostrdb::{Ndb, Transaction};
     23 use serde::{Deserialize, Serialize};
     24 use tokio::sync::oneshot;
     25 use uuid::Uuid;
     26 
     27 /// A question option from AskUserQuestion
     28 #[derive(Debug, Clone, Deserialize)]
     29 pub struct QuestionOption {
     30     pub label: String,
     31     pub description: String,
     32 }
     33 
     34 /// A single question from AskUserQuestion
     35 #[derive(Debug, Clone, Deserialize)]
     36 #[serde(rename_all = "camelCase")]
     37 pub struct UserQuestion {
     38     pub question: String,
     39     pub header: String,
     40     #[serde(default)]
     41     pub multi_select: bool,
     42     pub options: Vec<QuestionOption>,
     43 }
     44 
     45 /// Parsed AskUserQuestion tool input
     46 #[derive(Debug, Clone, Deserialize)]
     47 pub struct AskUserQuestionInput {
     48     pub questions: Vec<UserQuestion>,
     49 }
     50 
     51 /// User's answer to a question
     52 #[derive(Debug, Clone, Default, Serialize)]
     53 pub struct QuestionAnswer {
     54     /// Selected option indices
     55     pub selected: Vec<usize>,
     56     /// Custom "Other" text if provided
     57     pub other_text: Option<String>,
     58 }
     59 
     60 /// A request for user permission to use a tool (displayable data only)
     61 #[derive(Debug, Clone)]
     62 pub struct PermissionRequest {
     63     /// Unique identifier for this permission request
     64     pub id: Uuid,
     65     /// The tool that wants to be used
     66     pub tool_name: String,
     67     /// The arguments the tool will be called with
     68     pub tool_input: serde_json::Value,
     69     /// The user's response (None if still pending)
     70     pub response: Option<PermissionResponseType>,
     71     /// For AskUserQuestion: pre-computed summary of answers for display
     72     pub answer_summary: Option<AnswerSummary>,
     73     /// For ExitPlanMode: pre-parsed markdown with source text for span resolution
     74     pub cached_plan: Option<ParsedMarkdown>,
     75 }
     76 
     77 /// A single entry in an answer summary
     78 #[derive(Debug, Clone)]
     79 pub struct AnswerSummaryEntry {
     80     /// The question header (e.g., "Library", "Approach")
     81     pub header: String,
     82     /// The selected answer text, comma-separated if multiple
     83     pub answer: String,
     84 }
     85 
     86 /// Pre-computed summary of an AskUserQuestion response for display
     87 #[derive(Debug, Clone)]
     88 pub struct AnswerSummary {
     89     pub entries: Vec<AnswerSummaryEntry>,
     90 }
     91 
     92 /// A permission request with the response channel (for channel communication)
     93 pub struct PendingPermission {
     94     /// The displayable request data
     95     pub request: PermissionRequest,
     96     /// Channel to send the user's response back
     97     pub response_tx: oneshot::Sender<PermissionResponse>,
     98 }
     99 
    100 /// The user's response to a permission request
    101 #[derive(Debug, Clone)]
    102 pub enum PermissionResponse {
    103     /// Allow the tool to execute, with an optional message for the AI
    104     Allow { message: Option<String> },
    105     /// Deny the tool execution with a reason
    106     Deny { reason: String },
    107 }
    108 
    109 /// The recorded response type for display purposes (without channel details)
    110 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    111 pub enum PermissionResponseType {
    112     Allowed,
    113     Denied,
    114 }
    115 
    116 /// Metadata about a completed tool execution from an agentic backend.
    117 /// Used as a variant in `ToolResponses` to unify with other tool responses.
    118 #[derive(Debug, Clone, Serialize, Deserialize)]
    119 pub struct ExecutedTool {
    120     pub tool_name: String,
    121     pub summary: String, // e.g., "154 lines", "exit 0", "3 matches"
    122     /// Which subagent (Task tool_use_id) produced this result, if any
    123     pub parent_task_id: Option<String>,
    124     /// Pre-computed file update for diff rendering (not serialized)
    125     #[serde(skip)]
    126     pub file_update: Option<crate::file_update::FileUpdate>,
    127 }
    128 
    129 /// Session initialization info from Claude Code CLI
    130 #[derive(Debug, Clone, Default)]
    131 pub struct SessionInfo {
    132     /// Available tools in this session
    133     pub tools: Vec<String>,
    134     /// Model being used (e.g., "claude-opus-4-5-20251101")
    135     pub model: Option<String>,
    136     /// Permission mode (e.g., "default", "plan")
    137     pub permission_mode: Option<String>,
    138     /// Available slash commands
    139     pub slash_commands: Vec<String>,
    140     /// Available agent types for Task tool
    141     pub agents: Vec<String>,
    142     /// Claude Code CLI version
    143     pub cli_version: Option<String>,
    144     /// Current working directory
    145     pub cwd: Option<String>,
    146     /// Session ID from Claude Code
    147     pub claude_session_id: Option<String>,
    148 }
    149 
    150 /// Status of a subagent spawned by the Task tool
    151 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    152 pub enum SubagentStatus {
    153     /// Subagent is running
    154     Running,
    155     /// Subagent completed successfully
    156     Completed,
    157     /// Subagent failed with an error
    158     Failed,
    159 }
    160 
    161 /// Information about a subagent spawned by the Task tool
    162 #[derive(Debug, Clone)]
    163 pub struct SubagentInfo {
    164     /// Unique ID for this subagent task
    165     pub task_id: String,
    166     /// Description of what the subagent is doing
    167     pub description: String,
    168     /// Type of subagent (e.g., "Explore", "Plan", "Bash")
    169     pub subagent_type: String,
    170     /// Current status
    171     pub status: SubagentStatus,
    172     /// Output content (truncated for display)
    173     pub output: String,
    174     /// Maximum output size to keep (for size-restricted window)
    175     pub max_output_size: usize,
    176     /// Tool results produced by this subagent
    177     pub tool_results: Vec<ExecutedTool>,
    178 }
    179 
    180 /// An assistant message with incremental markdown parsing support.
    181 ///
    182 /// During streaming, tokens are pushed to the parser incrementally.
    183 /// After finalization (stream end), parsed elements are cached.
    184 pub struct AssistantMessage {
    185     /// Raw accumulated text (kept for API serialization)
    186     text: String,
    187     /// Incremental parser for this message (None after finalization)
    188     parser: Option<StreamParser>,
    189     /// Cached parsed elements (populated after finalization)
    190     cached_elements: Option<Vec<MdElement>>,
    191 }
    192 
    193 impl std::fmt::Debug for AssistantMessage {
    194     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    195         f.debug_struct("AssistantMessage")
    196             .field("text", &self.text)
    197             .field("is_streaming", &self.parser.is_some())
    198             .field(
    199                 "cached_elements",
    200                 &self.cached_elements.as_ref().map(|e| e.len()),
    201             )
    202             .finish()
    203     }
    204 }
    205 
    206 impl Clone for AssistantMessage {
    207     fn clone(&self) -> Self {
    208         // StreamParser doesn't implement Clone, so we need special handling.
    209         // For cloned messages (which are typically finalized), we just clone
    210         // the text and cached elements. If there's an active parser, we
    211         // re-parse from the raw text.
    212         if let Some(cached) = &self.cached_elements {
    213             Self {
    214                 text: self.text.clone(),
    215                 parser: None,
    216                 cached_elements: Some(cached.clone()),
    217             }
    218         } else {
    219             // Active streaming - re-parse from text
    220             let mut parser = StreamParser::new();
    221             parser.push(&self.text);
    222             Self {
    223                 text: self.text.clone(),
    224                 parser: Some(parser),
    225                 cached_elements: None,
    226             }
    227         }
    228     }
    229 }
    230 
    231 impl AssistantMessage {
    232     /// Create a new assistant message with a fresh parser.
    233     pub fn new() -> Self {
    234         Self {
    235             text: String::new(),
    236             parser: Some(StreamParser::new()),
    237             cached_elements: None,
    238         }
    239     }
    240 
    241     /// Create from existing text (e.g., when loading from storage).
    242     pub fn from_text(text: String) -> Self {
    243         let mut parser = StreamParser::new();
    244         parser.push(&text);
    245         parser.finalize();
    246         let cached = parser.parsed().to_vec();
    247         Self {
    248             text,
    249             parser: None,
    250             cached_elements: Some(cached),
    251         }
    252     }
    253 
    254     /// Push a new token and update the parser.
    255     pub fn push_token(&mut self, token: &str) {
    256         self.text.push_str(token);
    257         if let Some(parser) = &mut self.parser {
    258             parser.push(token);
    259         }
    260     }
    261 
    262     /// Finalize the message (call when stream ends).
    263     /// This caches the parsed elements and drops the parser.
    264     pub fn finalize(&mut self) {
    265         if let Some(mut parser) = self.parser.take() {
    266             parser.finalize();
    267             self.cached_elements = Some(parser.parsed().to_vec());
    268         }
    269     }
    270 
    271     /// Get the raw text content.
    272     pub fn text(&self) -> &str {
    273         &self.text
    274     }
    275 
    276     /// Get the buffer for resolving spans in parsed elements.
    277     /// This is the same as text() — both the parser and AssistantMessage
    278     /// maintain identical buffers via push_str(token).
    279     pub fn buffer(&self) -> &str {
    280         &self.text
    281     }
    282 
    283     /// Get parsed markdown elements.
    284     pub fn parsed_elements(&self) -> &[MdElement] {
    285         if let Some(cached) = &self.cached_elements {
    286             cached
    287         } else if let Some(parser) = &self.parser {
    288             parser.parsed()
    289         } else {
    290             &[]
    291         }
    292     }
    293 
    294     /// Get the current partial (in-progress) element, if any.
    295     pub fn partial(&self) -> Option<&Partial> {
    296         self.parser.as_ref().and_then(|p| p.partial())
    297     }
    298 
    299     /// Check if the message is still being streamed.
    300     pub fn is_streaming(&self) -> bool {
    301         self.parser.is_some()
    302     }
    303 }
    304 
    305 impl Default for AssistantMessage {
    306     fn default() -> Self {
    307         Self::new()
    308     }
    309 }
    310 
    311 #[derive(Debug, Clone)]
    312 pub enum Message {
    313     System(String),
    314     Error(String),
    315     User(String),
    316     Assistant(AssistantMessage),
    317     ToolCalls(Vec<ToolCall>),
    318     ToolResponse(ToolResponse),
    319     /// A permission request from the AI that needs user response
    320     PermissionRequest(PermissionRequest),
    321     /// Conversation was compacted
    322     CompactionComplete(CompactionInfo),
    323     /// A subagent spawned by Task tool
    324     Subagent(SubagentInfo),
    325 }
    326 
    327 /// Compaction info from compact_boundary system message
    328 #[derive(Debug, Clone)]
    329 pub struct CompactionInfo {
    330     /// Number of tokens before compaction
    331     pub pre_tokens: u64,
    332 }
    333 
    334 /// Usage metrics from a completed query's Result message
    335 #[derive(Debug, Clone, Default)]
    336 pub struct UsageInfo {
    337     pub input_tokens: u64,
    338     pub output_tokens: u64,
    339     pub cost_usd: Option<f64>,
    340     pub num_turns: u32,
    341 }
    342 
    343 impl UsageInfo {
    344     /// Context window fill: only input tokens consume context space.
    345     /// Output tokens are generated from the context, not part of it.
    346     pub fn context_tokens(&self) -> u64 {
    347         self.input_tokens
    348     }
    349 }
    350 
    351 /// Get context window size for a model name.
    352 /// All current Claude models have 200K context.
    353 pub fn context_window_for_model(_model: Option<&str>) -> u64 {
    354     200_000
    355 }
    356 
    357 /// The ai backends response. Since we are using streaming APIs these are
    358 /// represented as individual tokens or tool calls
    359 pub enum DaveApiResponse {
    360     ToolCalls(Vec<ToolCall>),
    361     Token(String),
    362     Failed(String),
    363     /// A permission request that needs to be displayed to the user
    364     PermissionRequest(PendingPermission),
    365     /// Metadata from a completed tool execution
    366     ToolResult(ExecutedTool),
    367     /// Session initialization info from Claude Code CLI
    368     SessionInfo(SessionInfo),
    369     /// Subagent spawned by Task tool
    370     SubagentSpawned(SubagentInfo),
    371     /// Subagent output update
    372     SubagentOutput {
    373         task_id: String,
    374         output: String,
    375     },
    376     /// Subagent completed
    377     SubagentCompleted {
    378         task_id: String,
    379         result: String,
    380     },
    381     /// Conversation compaction started
    382     CompactionStarted,
    383     /// Conversation compaction completed with token info
    384     CompactionComplete(CompactionInfo),
    385     /// Query completed with usage metrics
    386     QueryComplete(UsageInfo),
    387 }
    388 
    389 impl Message {
    390     pub fn tool_error(id: String, msg: String) -> Self {
    391         Self::ToolResponse(ToolResponse::error(id, msg))
    392     }
    393 
    394     pub fn to_api_msg(&self, txn: &Transaction, ndb: &Ndb) -> Option<ChatCompletionRequestMessage> {
    395         match self {
    396             Message::Error(_err) => None,
    397 
    398             Message::User(msg) => Some(ChatCompletionRequestMessage::User(
    399                 ChatCompletionRequestUserMessage {
    400                     name: None,
    401                     content: ChatCompletionRequestUserMessageContent::Text(msg.clone()),
    402                 },
    403             )),
    404 
    405             Message::Assistant(msg) => Some(ChatCompletionRequestMessage::Assistant(
    406                 ChatCompletionRequestAssistantMessage {
    407                     content: Some(ChatCompletionRequestAssistantMessageContent::Text(
    408                         msg.text().to_string(),
    409                     )),
    410                     ..Default::default()
    411                 },
    412             )),
    413 
    414             Message::System(msg) => Some(ChatCompletionRequestMessage::System(
    415                 ChatCompletionRequestSystemMessage {
    416                     content: ChatCompletionRequestSystemMessageContent::Text(msg.clone()),
    417                     ..Default::default()
    418                 },
    419             )),
    420 
    421             Message::ToolCalls(calls) => Some(ChatCompletionRequestMessage::Assistant(
    422                 ChatCompletionRequestAssistantMessage {
    423                     tool_calls: Some(calls.iter().map(|c| c.to_api()).collect()),
    424                     ..Default::default()
    425                 },
    426             )),
    427 
    428             Message::ToolResponse(resp) => {
    429                 // ExecutedTool results are UI-only, not sent to the API
    430                 if matches!(resp.responses(), ToolResponses::ExecutedTool(_)) {
    431                     return None;
    432                 }
    433 
    434                 let tool_response = resp.responses().format_for_dave(txn, ndb);
    435 
    436                 Some(ChatCompletionRequestMessage::Tool(
    437                     ChatCompletionRequestToolMessage {
    438                         tool_call_id: resp.id().to_owned(),
    439                         content: ChatCompletionRequestToolMessageContent::Text(tool_response),
    440                     },
    441                 ))
    442             }
    443 
    444             // Permission requests are UI-only, not sent to the API
    445             Message::PermissionRequest(_) => None,
    446 
    447             // Compaction complete is UI-only, not sent to the API
    448             Message::CompactionComplete(_) => None,
    449 
    450             // Subagent info is UI-only, not sent to the API
    451             Message::Subagent(_) => None,
    452         }
    453     }
    454 }