diff --git a/crates/goose/src/session/storage.rs b/crates/goose/src/session/storage.rs index 7be86224..3925811d 100644 --- a/crates/goose/src/session/storage.rs +++ b/crates/goose/src/session/storage.rs @@ -421,4 +421,129 @@ mod tests { // Time part should be 6 digits assert_eq!(parts[1].len(), 6); } + + #[tokio::test] + async fn test_special_characters_and_long_text() -> Result<()> { + let dir = tempdir()?; + let file_path = dir.path().join("special.jsonl"); + + // Insert some problematic JSON-like content between long text + let long_text = format!( + "Start_of_message\n{}{}SOME_MIDDLE_TEXT{}End_of_message", + "A".repeat(100_000), + "\"}]\n", + "A".repeat(100_000) + ); + + let special_chars = vec![ + // Long text + long_text.as_str(), + // Newlines in different positions + "Line 1\nLine 2", + "Line 1\r\nLine 2", + "\nStart with newline", + "End with newline\n", + "\n\nMultiple\n\nNewlines\n\n", + // JSON special characters + "Quote\"in middle", + "\"Quote at start", + "Quote at end\"", + "Multiple\"\"Quotes", + "{\"json\": \"looking text\"}", + // Unicode and special characters + "Unicode: 🦆🤖👾", + "Special: \\n \\r \\t", + "Mixed: \n\"🦆\"\r\n\\n", + // Control characters + "Tab\there", + "Bell\u{0007}char", + "Null\u{0000}char", + // Long text with mixed content + "A very long message with multiple lines\nand \"quotes\"\nand emojis 🦆\nand \\escaped chars", + // Potentially problematic JSON content + "}{[]\",\\", + "]}}\"\\n\\\"{[", + "Edge case: } ] some text", + "{\"foo\": \"} ]\"}", + "}]", + ]; + + let mut messages = Vec::new(); + for text in special_chars { + messages.push(Message::user().with_text(text)); + messages.push(Message::assistant().with_text(text)); + } + + // Write messages with special characters + persist_messages(&file_path, &messages, None).await?; + + // Read them back + let read_messages = read_messages(&file_path)?; + + // Compare all messages + assert_eq!(messages.len(), read_messages.len()); + for (i, (orig, read)) in messages.iter().zip(read_messages.iter()).enumerate() { + assert_eq!(orig.role, read.role, "Role mismatch at message {}", i); + assert_eq!( + orig.content.len(), + read.content.len(), + "Content length mismatch at message {}", + i + ); + + if let (Some(MessageContent::Text(orig_text)), Some(MessageContent::Text(read_text))) = + (orig.content.first(), read.content.first()) + { + assert_eq!( + orig_text.text, read_text.text, + "Text mismatch at message {}\nExpected: {}\nGot: {}", + i, orig_text.text, read_text.text + ); + } else { + panic!("Messages don't match expected structure at index {}", i); + } + } + + // Verify file format + let contents = fs::read_to_string(&file_path)?; + let lines: Vec<&str> = contents.lines().collect(); + + // First line should be metadata + assert!( + lines[0].contains("\"description\""), + "First line should be metadata" + ); + + // Each subsequent line should be valid JSON + for (i, line) in lines.iter().enumerate().skip(1) { + assert!( + serde_json::from_str::(line).is_ok(), + "Invalid JSON at line {}: {}", + i + 1, + line + ); + } + + Ok(()) + } + + #[tokio::test] + async fn test_metadata_special_chars() -> Result<()> { + let dir = tempdir()?; + let file_path = dir.path().join("metadata.jsonl"); + + let mut metadata = SessionMetadata::default(); + metadata.description = "Description with\nnewline and \"quotes\" and 🦆".to_string(); + + let messages = vec![Message::user().with_text("test")]; + + // Write with special metadata + save_messages_with_metadata(&file_path, &metadata, &messages)?; + + // Read back metadata + let read_metadata = read_metadata(&file_path)?; + assert_eq!(metadata.description, read_metadata.description); + + Ok(()) + } } diff --git a/ui/desktop/src/components/sessions/SessionsView.tsx b/ui/desktop/src/components/sessions/SessionsView.tsx index 9aed293d..8e8d9d6b 100644 --- a/ui/desktop/src/components/sessions/SessionsView.tsx +++ b/ui/desktop/src/components/sessions/SessionsView.tsx @@ -1,14 +1,9 @@ import React, { useState } from 'react'; import { ViewConfig } from '../../App'; import { fetchSessionDetails, type SessionDetails } from '../../sessions'; -import { fetchSharedSessionDetails } from '../../sharedSessions'; import SessionListView from './SessionListView'; import SessionHistoryView from './SessionHistoryView'; -import { Card } from '../ui/card'; -import { Input } from '../ui/input'; -import { Button } from '../ui/button'; -import BackButton from '../ui/BackButton'; -import { ScrollArea } from '../ui/scroll-area'; +import { toastError } from '../../toasts'; interface SessionsViewProps { setView: (view: ViewConfig['view'], viewOptions?: Record) => void; @@ -34,6 +29,12 @@ const SessionsView: React.FC = ({ setView }) => { setError('Failed to load session details. Please try again later.'); // Keep the selected session null if there's an error setSelectedSession(null); + + toastError({ + title: 'Failed to load session. The file may be corrupted.', + msg: 'Please try again later.', + traceback: err, + }); } finally { setIsLoadingSession(false); }