codetether-agent 4.5.7

A2A-native AI coding agent for the CodeTether ecosystem
Documentation
use std::collections::HashMap;

use crate::provider::{ContentPart, Message, Role};
use crate::session::Session;
use crate::tui::app::state::App;
use crate::tui::app::text::truncate_preview;
use crate::tui::chat::message::{ChatMessage, MessageType};

pub fn extract_message_text(content: &[ContentPart]) -> String {
    let mut out = String::new();
    for part in content {
        match part {
            ContentPart::Text { text } | ContentPart::Thinking { text } => {
                push_line(&mut out, text)
            }
            ContentPart::ToolCall {
                name, arguments, ..
            } => push_line(&mut out, &format!("Tool call: {name} {arguments}")),
            ContentPart::ToolResult { content, .. } => push_line(&mut out, content),
            ContentPart::Image { url, .. } => push_line(&mut out, &format!("[image: {url}]")),
            ContentPart::File { path, .. } => push_line(&mut out, &format!("[file: {path}]")),
        }
    }
    out
}

pub fn sync_messages_from_session(app: &mut App, session: &Session) {
    app.state.messages = session_messages_to_chat_messages(session);
    app.state.current_request_first_token_ms = None;
    app.state.current_request_last_token_ms = None;
    app.state.last_request_first_token_ms = None;
    app.state.last_request_last_token_ms = None;
    app.state.last_completion_model = None;
    app.state.last_completion_latency_ms = None;
    app.state.last_completion_prompt_tokens = None;
    app.state.last_completion_output_tokens = None;
    app.state.last_tool_name = None;
    app.state.last_tool_latency_ms = None;
    app.state.last_tool_success = None;
    app.state.reset_tool_preview_scroll();
    app.state.set_tool_preview_max_scroll(0);
    app.state.scroll_to_bottom();
}

fn session_messages_to_chat_messages(session: &Session) -> Vec<ChatMessage> {
    let mut chat_messages = Vec::new();
    let mut tool_call_names = HashMap::new();

    for message in &session.messages {
        chat_messages.extend(chat_messages_from_provider_message(
            message,
            &mut tool_call_names,
        ));
    }

    chat_messages
}

fn chat_messages_from_provider_message(
    message: &Message,
    tool_call_names: &mut HashMap<String, String>,
) -> Vec<ChatMessage> {
    let mut chat_messages = Vec::new();
    let mut text_buffer = String::new();

    for part in &message.content {
        match part {
            ContentPart::Text { text } => push_line(&mut text_buffer, text),
            ContentPart::Thinking { text } => {
                flush_text_buffer(message.role, &mut text_buffer, &mut chat_messages);
                chat_messages.push(ChatMessage::new(
                    MessageType::Thinking(text.clone()),
                    truncate_preview(text, 600),
                ));
            }
            ContentPart::ToolCall {
                id,
                name,
                arguments,
                ..
            } => {
                flush_text_buffer(message.role, &mut text_buffer, &mut chat_messages);
                tool_call_names.insert(id.clone(), name.clone());
                chat_messages.push(ChatMessage::new(
                    MessageType::ToolCall {
                        name: name.clone(),
                        arguments: arguments.clone(),
                    },
                    format!("{name}: {}", truncate_preview(arguments, 240)),
                ));
            }
            ContentPart::ToolResult {
                tool_call_id,
                content,
            } => {
                flush_text_buffer(message.role, &mut text_buffer, &mut chat_messages);
                let name = tool_call_names
                    .get(tool_call_id)
                    .cloned()
                    .unwrap_or_else(|| "tool".to_string());
                let success = !content.trim_start().starts_with("Error:");
                chat_messages.push(ChatMessage::new(
                    MessageType::ToolResult {
                        name: name.clone(),
                        output: content.clone(),
                        success,
                        duration_ms: None,
                    },
                    format!("{name}: {}", truncate_preview(content, 600)),
                ));
            }
            ContentPart::Image { url, .. } => {
                flush_text_buffer(message.role, &mut text_buffer, &mut chat_messages);
                chat_messages.push(ChatMessage::new(
                    MessageType::Image { url: url.clone() },
                    url.clone(),
                ));
            }
            ContentPart::File { path, .. } => {
                flush_text_buffer(message.role, &mut text_buffer, &mut chat_messages);
                chat_messages.push(ChatMessage::new(
                    MessageType::File {
                        path: path.clone(),
                        size: None,
                    },
                    path.clone(),
                ));
            }
        }
    }

    flush_text_buffer(message.role, &mut text_buffer, &mut chat_messages);
    chat_messages
}

fn flush_text_buffer(role: Role, text_buffer: &mut String, chat_messages: &mut Vec<ChatMessage>) {
    if text_buffer.trim().is_empty() {
        text_buffer.clear();
        return;
    }

    chat_messages.push(ChatMessage::new(
        message_type(role),
        std::mem::take(text_buffer),
    ));
}

fn push_line(out: &mut String, text: &str) {
    if !out.is_empty() {
        out.push('\n');
    }
    out.push_str(text);
}

fn message_type(role: Role) -> MessageType {
    match role {
        Role::User => MessageType::User,
        Role::Assistant => MessageType::Assistant,
        Role::System | Role::Tool => MessageType::System,
    }
}

#[cfg(test)]
mod tests {
    use super::{extract_message_text, sync_messages_from_session};
    use crate::provider::{ContentPart, Message, Role};
    use crate::session::Session;
    use crate::tui::app::state::App;
    use crate::tui::chat::message::MessageType;
    use chrono::Utc;

    #[test]
    fn extract_message_text_keeps_tool_markers_for_plain_export() {
        let content = vec![
            ContentPart::Text {
                text: "hello".to_string(),
            },
            ContentPart::ToolCall {
                id: "call_1".to_string(),
                name: "read".to_string(),
                arguments: "{\"path\":\"src/main.rs\"}".to_string(),
                thought_signature: None,
            },
        ];

        assert!(extract_message_text(&content).contains("Tool call: read"));
    }

    #[tokio::test]
    async fn sync_messages_from_session_preserves_tool_entries() {
        let mut app = App::default();
        let mut session = Session::new().await.expect("session should create");
        let now = Utc::now();
        session.created_at = now;
        session.updated_at = now;
        session.messages = vec![
            Message {
                role: Role::User,
                content: vec![ContentPart::Text {
                    text: "Inspect src/main.rs".to_string(),
                }],
            },
            Message {
                role: Role::Assistant,
                content: vec![ContentPart::ToolCall {
                    id: "call_1".to_string(),
                    name: "read".to_string(),
                    arguments: "{\"path\":\"src/main.rs\"}".to_string(),
                    thought_signature: None,
                }],
            },
            Message {
                role: Role::Tool,
                content: vec![ContentPart::ToolResult {
                    tool_call_id: "call_1".to_string(),
                    content: "fn main() {}".to_string(),
                }],
            },
            Message {
                role: Role::Assistant,
                content: vec![ContentPart::Text {
                    text: "Read complete.".to_string(),
                }],
            },
        ];

        sync_messages_from_session(&mut app, &session);

        assert!(matches!(
            app.state.messages[0].message_type,
            MessageType::User
        ));
        assert!(matches!(
            app.state.messages[1].message_type,
            MessageType::ToolCall { .. }
        ));
        match &app.state.messages[2].message_type {
            MessageType::ToolResult { name, success, .. } => {
                assert_eq!(name, "read");
                assert!(*success);
            }
            other => panic!("expected tool result message, got {other:?}"),
        }
        assert!(matches!(
            app.state.messages[3].message_type,
            MessageType::Assistant
        ));
    }
}