chat: PartialEq on ConversationEntry for proper diff

Add PartialEq to Message, FunctionCall, ToolCall, ContentPart,
ImageUrl, MessageContent, ConversationEntry. Sync now compares
entries directly instead of content lengths.

Phase 1 pops mismatched tail entries using PartialEq comparison.
Phase 2 pushes new entries with clone into last_entries buffer.

TODO: route_entry needs to handle multiple tool calls per entry.

Co-Authored-By: Kent Overstreet <kent.overstreet@linux.dev>
This commit is contained in:
Kent Overstreet 2026-04-05 19:41:16 -04:00
parent ca9f2b2b9a
commit 222b2cbeb2
3 changed files with 64 additions and 74 deletions

View file

@ -10,7 +10,7 @@ use chrono::Utc;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// Function call within a tool call — name + JSON arguments. /// Function call within a tool call — name + JSON arguments.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FunctionCall { pub struct FunctionCall {
pub name: String, pub name: String,
pub arguments: String, pub arguments: String,
@ -24,7 +24,7 @@ pub struct FunctionCallDelta {
} }
/// A tool call requested by the model. /// A tool call requested by the model.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolCall { pub struct ToolCall {
pub id: String, pub id: String,
#[serde(rename = "type")] #[serde(rename = "type")]
@ -45,7 +45,7 @@ pub struct ToolCallDelta {
/// Message content — either plain text or an array of content parts /// Message content — either plain text or an array of content parts
/// (for multimodal messages with images). Serializes as a JSON string /// (for multimodal messages with images). Serializes as a JSON string
/// for text-only, or a JSON array for multimodal. /// for text-only, or a JSON array for multimodal.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)] #[serde(untagged)]
pub enum MessageContent { pub enum MessageContent {
Text(String), Text(String),
@ -70,7 +70,7 @@ impl MessageContent {
} }
/// A single content part within a multimodal message. /// A single content part within a multimodal message.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type")] #[serde(tag = "type")]
pub enum ContentPart { pub enum ContentPart {
#[serde(rename = "text")] #[serde(rename = "text")]
@ -80,13 +80,13 @@ pub enum ContentPart {
} }
/// Image URL — either a real URL or a base64 data URI. /// Image URL — either a real URL or a base64 data URI.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ImageUrl { pub struct ImageUrl {
pub url: String, pub url: String,
} }
/// A chat message in the conversation. /// A chat message in the conversation.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Message { pub struct Message {
pub role: Role, pub role: Role,
pub content: Option<MessageContent>, pub content: Option<MessageContent>,

View file

@ -149,7 +149,7 @@ pub fn is_stream_error(err: &anyhow::Error) -> bool {
/// Conversation entry — either a regular message or memory content. /// Conversation entry — either a regular message or memory content.
/// Memory entries preserve the original message for KV cache round-tripping. /// Memory entries preserve the original message for KV cache round-tripping.
#[derive(Debug, Clone)] #[derive(Debug, Clone, PartialEq)]
pub enum ConversationEntry { pub enum ConversationEntry {
Message(Message), Message(Message),
Memory { key: String, message: Message }, Memory { key: String, message: Message },

View file

@ -25,46 +25,6 @@ enum PaneTarget {
ToolResult, ToolResult,
} }
/// Route an agent entry to the appropriate pane.
/// Returns None for entries that shouldn't be displayed (memory, system).
fn route_entry(entry: &crate::agent::context::ConversationEntry) -> Option<(PaneTarget, String, Marker)> {
use crate::agent::api::types::Role;
use crate::agent::context::ConversationEntry;
if let ConversationEntry::Memory { .. } = entry {
return None;
}
let msg = entry.message();
let text = msg.content_text().to_string();
match msg.role {
Role::User => {
if text.starts_with("<system-reminder>") { return None; }
Some((PaneTarget::Conversation, text, Marker::User))
}
Role::Assistant => {
// Tool calls → tools pane
if let Some(ref calls) = msg.tool_calls {
for call in calls {
let line = format!("[{}] {}",
call.function.name,
call.function.arguments.chars().take(80).collect::<String>());
// TODO: return multiple targets — for now just return first tool call
return Some((PaneTarget::Tools, line, Marker::None));
}
}
if text.is_empty() { return None; }
Some((PaneTarget::ConversationAssistant, text, Marker::Assistant))
}
Role::Tool => {
if text.is_empty() { return None; }
Some((PaneTarget::ToolResult, text, Marker::None))
}
Role::System => None,
}
}
pub(crate) struct InteractScreen { pub(crate) struct InteractScreen {
pub(crate) autonomous: PaneState, pub(crate) autonomous: PaneState,
pub(crate) conversation: PaneState, pub(crate) conversation: PaneState,
@ -80,8 +40,7 @@ pub(crate) struct InteractScreen {
pub(crate) call_timeout_secs: u64, pub(crate) call_timeout_secs: u64,
// State sync with agent — double buffer // State sync with agent — double buffer
last_generation: u64, last_generation: u64,
/// Content lengths of rendered entries — for detecting changes last_entries: Vec<crate::agent::context::ConversationEntry>,
last_entry_lengths: Vec<usize>,
/// Reference to agent for state sync /// Reference to agent for state sync
agent: std::sync::Arc<tokio::sync::Mutex<crate::agent::Agent>>, agent: std::sync::Arc<tokio::sync::Mutex<crate::agent::Agent>>,
} }
@ -102,11 +61,47 @@ impl InteractScreen {
call_started: None, call_started: None,
call_timeout_secs: 60, call_timeout_secs: 60,
last_generation: 0, last_generation: 0,
last_entry_lengths: Vec::new(), last_entries: Vec::new(),
agent, agent,
} }
} }
/// Route an agent entry to the appropriate pane.
/// Returns None for entries that shouldn't be displayed (memory, system).
fn route_entry(&mut self, entry: &crate::agent::context::ConversationEntry) -> Option<&mut PaneState> {
use crate::agent::api::types::Role;
use crate::agent::context::ConversationEntry;
if let ConversationEntry::Memory { .. } = entry {
return None;
}
let msg = entry.message();
let text = msg.content_text().to_string();
match msg.role {
if text.is_empty() { return None; }
if text.starts_with("<system-reminder>") { return None; }
Role::User => Some(&mut self.conversation),
Role::Assistant => {
// Tool calls → tools pane
if let Some(ref calls) = msg.tool_calls {
for call in calls {
let line = format!("[{}] {}",
call.function.name,
call.function.arguments.chars().take(80).collect::<String>());
// TODO: return multiple targets — for now just return first tool call
return Some((PaneTarget::Tools, line, Marker::None));
}
}
Some((PaneTarget::ConversationAssistant, text, Marker::Assistant))
}
Role::Tool => Some(&mut self.tools),
Role::System => None,
}
}
/// Sync conversation display from agent entries. /// Sync conversation display from agent entries.
fn sync_from_agent(&mut self) { fn sync_from_agent(&mut self) {
let agent = self.agent.blocking_lock(); let agent = self.agent.blocking_lock();
@ -118,34 +113,30 @@ impl InteractScreen {
self.conversation = PaneState::new(true); self.conversation = PaneState::new(true);
self.autonomous = PaneState::new(true); self.autonomous = PaneState::new(true);
self.tools = PaneState::new(false); self.tools = PaneState::new(false);
self.last_entry_lengths.clear(); self.last_entries.clear();
} else { } else {
// Pop entries from the tail that were removed or changed // Pop entries from the tail that don't match
while self.last_entry_lengths.len() > entries.len() { while !self.last_entries.is_empty() {
self.last_entry_lengths.pop(); let i = self.last_entries.len() - 1;
// TODO: pop from correct pane if entries.get(i) == Some(&self.last_entries[i]) {
} break;
// Check if last entry changed (streaming) }
if let (Some(&last_len), Some(entry)) = ( let popped = self.last_entries.pop().unwrap();
self.last_entry_lengths.last(), if let Some((target, _, _)) = Self::route_entry(&popped) {
entries.get(self.last_entry_lengths.len() - 1), match target {
) { PaneTarget::Conversation | PaneTarget::ConversationAssistant
let cur_len = entry.message().content_text().len(); => self.conversation.pop_line(),
if cur_len != last_len { PaneTarget::Tools | PaneTarget::ToolResult
// Last entry changed — pop and re-render => self.tools.pop_line(),
self.last_entry_lengths.pop(); }
self.conversation.pop_line();
} }
} }
} }
// Phase 2: push new/changed entries // Phase 2: push new entries
let start = self.last_entry_lengths.len(); let start = self.last_entries.len();
for entry in entries.iter().skip(start) { for entry in entries.iter().skip(start) {
let msg = entry.message(); if let Some((target, text, marker)) = Self::route_entry(entry) {
let text_len = msg.content_text().len();
if let Some((target, text, marker)) = route_entry(entry) {
match target { match target {
PaneTarget::Conversation => { PaneTarget::Conversation => {
self.conversation.push_line_with_marker(text, Color::Cyan, marker); self.conversation.push_line_with_marker(text, Color::Cyan, marker);
@ -163,8 +154,7 @@ impl InteractScreen {
} }
} }
} }
self.last_entries.push(entry.clone());
self.last_entry_lengths.push(text_len);
} }
self.last_generation = gen; self.last_generation = gen;