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:
parent
ca9f2b2b9a
commit
222b2cbeb2
3 changed files with 64 additions and 74 deletions
|
|
@ -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>,
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
|
|
||||||
122
src/user/chat.rs
122
src/user/chat.rs
|
|
@ -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;
|
||||||
|
}
|
||||||
|
let popped = self.last_entries.pop().unwrap();
|
||||||
|
if let Some((target, _, _)) = Self::route_entry(&popped) {
|
||||||
|
match target {
|
||||||
|
PaneTarget::Conversation | PaneTarget::ConversationAssistant
|
||||||
|
=> self.conversation.pop_line(),
|
||||||
|
PaneTarget::Tools | PaneTarget::ToolResult
|
||||||
|
=> self.tools.pop_line(),
|
||||||
}
|
}
|
||||||
// Check if last entry changed (streaming)
|
|
||||||
if let (Some(&last_len), Some(entry)) = (
|
|
||||||
self.last_entry_lengths.last(),
|
|
||||||
entries.get(self.last_entry_lengths.len() - 1),
|
|
||||||
) {
|
|
||||||
let cur_len = entry.message().content_text().len();
|
|
||||||
if cur_len != last_len {
|
|
||||||
// Last entry changed — pop and re-render
|
|
||||||
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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue