From 9825b4782f1f62170d6581f4552fc347c34e488b Mon Sep 17 00:00:00 2001 From: lc6464 Date: Tue, 26 May 2026 14:05:20 +0800 Subject: [PATCH] fix(seahorse,session): preserve created_at across history bootstrap --- pkg/agent/context_seahorse.go | 9 ++ pkg/agent/context_seahorse_test.go | 5 ++ pkg/seahorse/short_engine.go | 132 +++++++++++++++++++++++------ pkg/seahorse/short_engine_test.go | 86 +++++++++++++++++-- pkg/seahorse/store.go | 92 ++++++++++++++++---- pkg/seahorse/store_test.go | 14 +++ pkg/session/manager.go | 29 ++++--- pkg/session/manager_test.go | 29 +++++++ 8 files changed, 340 insertions(+), 56 deletions(-) diff --git a/pkg/agent/context_seahorse.go b/pkg/agent/context_seahorse.go index 2a10d2457..6bd32c216 100644 --- a/pkg/agent/context_seahorse.go +++ b/pkg/agent/context_seahorse.go @@ -6,6 +6,7 @@ import ( "context" "encoding/json" "fmt" + "time" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" @@ -200,6 +201,7 @@ func providerToSeahorseMessage(msg protocoltypes.Message) seahorse.Message { ModelName: msg.ModelName, ReasoningContent: msg.ReasoningContent, TokenCount: tokenizer.EstimateMessageTokens(msg), + CreatedAt: normalizeSeahorseMessageCreatedAt(msg.CreatedAt), } // Convert ToolCalls → MessageParts @@ -235,6 +237,13 @@ func providerToSeahorseMessage(msg protocoltypes.Message) seahorse.Message { return result } +func normalizeSeahorseMessageCreatedAt(createdAt *time.Time) time.Time { + if createdAt == nil || createdAt.IsZero() { + return time.Time{} + } + return createdAt.UTC().Truncate(time.Second) +} + // seahorseToProviderMessages converts a seahorse.AssembleResult to []providers.Message. func seahorseToProviderMessages(result *seahorse.AssembleResult) []protocoltypes.Message { messages := make([]protocoltypes.Message, 0, len(result.Messages)) diff --git a/pkg/agent/context_seahorse_test.go b/pkg/agent/context_seahorse_test.go index 101f72ee2..497e1fc44 100644 --- a/pkg/agent/context_seahorse_test.go +++ b/pkg/agent/context_seahorse_test.go @@ -171,11 +171,13 @@ func TestProviderToSeahorseMessageWithMedia(t *testing.T) { } func TestProviderToSeahorseMessageWithReasoning(t *testing.T) { + createdAt := time.Date(2026, 5, 6, 7, 8, 9, 123000000, time.UTC) msg := protocoltypes.Message{ Role: "assistant", Content: "response text", ModelName: "gpt-5.4-mini", ReasoningContent: "I thought about this carefully", + CreatedAt: &createdAt, } result := providerToSeahorseMessage(msg) @@ -185,6 +187,9 @@ func TestProviderToSeahorseMessageWithReasoning(t *testing.T) { if result.ModelName != "gpt-5.4-mini" { t.Errorf("ModelName = %q, want %q", result.ModelName, "gpt-5.4-mini") } + if !result.CreatedAt.Equal(time.Date(2026, 5, 6, 7, 8, 9, 0, time.UTC)) { + t.Errorf("CreatedAt = %v, want 2026-05-06 07:08:09 UTC", result.CreatedAt) + } } func TestSeahorseToProviderMessagesWithReasoning(t *testing.T) { diff --git a/pkg/seahorse/short_engine.go b/pkg/seahorse/short_engine.go index d275da516..ef493bd18 100644 --- a/pkg/seahorse/short_engine.go +++ b/pkg/seahorse/short_engine.go @@ -9,6 +9,7 @@ import ( "regexp" "strings" "sync" + "time" _ "modernc.org/sqlite" @@ -261,6 +262,7 @@ func (e *Engine) Ingest(ctx context.Context, sessionKey string, messages []Messa msg.ModelName, msg.ReasoningContent, msg.TokenCount, + msg.CreatedAt, ) } else { added, err = e.store.AddMessageWithReasoning( @@ -271,6 +273,7 @@ func (e *Engine) Ingest(ctx context.Context, sessionKey string, messages []Messa msg.ModelName, msg.ReasoningContent, msg.TokenCount, + msg.CreatedAt, ) } if err != nil { @@ -445,10 +448,14 @@ func (e *Engine) Bootstrap(ctx context.Context, sessionKey string, messages []Me if err != nil { return fmt.Errorf("bootstrap: repair model_name: %w", err) } - if (repairedReasoning || repairedModelName) && len(dbMsgs) == len(messages) { + repairedCreatedAt, err := e.repairBootstrapCreatedAt(ctx, dbMsgs, messages) + if err != nil { + return fmt.Errorf("bootstrap: repair created_at: %w", err) + } + if (repairedReasoning || repairedModelName || repairedCreatedAt) && len(dbMsgs) == len(messages) { matched := true for i := range messages { - if !messageMatches(dbMsgs[i], messages[i]) { + if !messagesMatch(dbMsgs[i], messages[i], messageMatchOptions{}) { matched = false break } @@ -462,7 +469,7 @@ func (e *Engine) Bootstrap(ctx context.Context, sessionKey string, messages []Me if len(dbMsgs) == len(messages) { matched := true for i := range messages { - if !messageMatches(dbMsgs[i], messages[i]) { + if !messagesMatch(dbMsgs[i], messages[i], messageMatchOptions{}) { matched = false break } @@ -477,7 +484,7 @@ func (e *Engine) Bootstrap(ctx context.Context, sessionKey string, messages []Me compareLen := min(len(dbMsgs), len(messages)) for i := range compareLen { - if messageMatches(dbMsgs[i], messages[i]) { + if messagesMatch(dbMsgs[i], messages[i], messageMatchOptions{}) { anchor = i } else { // Mismatch detected - log details and rebuild @@ -578,7 +585,11 @@ func (e *Engine) repairBootstrapReasoningContent(ctx context.Context, dbMsgs, me } for i := range overlap { - if !messageMatchesIgnoringReasoningAndModelName(dbMsgs[i], messages[i]) { + if !messagesMatch(dbMsgs[i], messages[i], messageMatchOptions{ + IgnoreReasoningContent: true, + IgnoreModelName: true, + IgnoreCreatedAt: true, + }) { return false, nil } if dbMsgs[i].ReasoningContent == messages[i].ReasoningContent { @@ -629,7 +640,11 @@ func (e *Engine) repairBootstrapModelName(ctx context.Context, dbMsgs, messages } for i := range overlap { - if !messageMatchesIgnoringReasoningAndModelName(dbMsgs[i], messages[i]) { + if !messagesMatch(dbMsgs[i], messages[i], messageMatchOptions{ + IgnoreReasoningContent: true, + IgnoreModelName: true, + IgnoreCreatedAt: true, + }) { return false, nil } if dbMsgs[i].ModelName == messages[i].ModelName { @@ -666,6 +681,64 @@ func (e *Engine) repairBootstrapModelName(ctx context.Context, dbMsgs, messages return true, nil } +func (e *Engine) repairBootstrapCreatedAt(ctx context.Context, dbMsgs, messages []Message) (bool, error) { + if len(dbMsgs) == 0 || len(messages) == 0 { + return false, nil + } + + overlap := min(len(messages), len(dbMsgs)) + + var updates []struct { + index int + messageID int64 + createdAt time.Time + } + + for i := range overlap { + if !messagesMatch(dbMsgs[i], messages[i], messageMatchOptions{ + IgnoreReasoningContent: true, + IgnoreModelName: true, + IgnoreCreatedAt: true, + }) { + return false, nil + } + + wantCreatedAt := normalizeMessageCreatedAt(messages[i].CreatedAt) + if wantCreatedAt.IsZero() { + return false, nil + } + if dbMsgs[i].CreatedAt.Equal(wantCreatedAt) { + continue + } + + updates = append(updates, struct { + index int + messageID int64 + createdAt time.Time + }{ + index: i, + messageID: dbMsgs[i].ID, + createdAt: wantCreatedAt, + }) + } + + if len(updates) == 0 { + return false, nil + } + + for _, update := range updates { + if err := e.store.UpdateMessageCreatedAt(ctx, update.messageID, update.createdAt); err != nil { + return false, err + } + dbMsgs[update.index].CreatedAt = update.createdAt + } + + logger.InfoCF("seahorse", "bootstrap: repaired message created_at", map[string]any{ + "messages": len(updates), + }) + return true, nil +} + // truncate shortens a string for logging. func truncate(s string, maxLen int) string { if len(s) <= maxLen { @@ -674,29 +747,28 @@ func truncate(s string, maxLen int) string { return s[:maxLen] + "..." } -// messageMatches compares two messages using role + reasoning_content and then -// either content or parts. TokenCount is NOT compared because it may be -// re-estimated differently during bootstrap (e.g., via tokenizer.EstimateMessageTokens). -// For messages with Parts (tool_use, tool_result), compare Parts instead of Content -// because structured messages are matched by their parts payload. -func messageMatches(a, b Message) bool { - if a.Role != b.Role || a.ReasoningContent != b.ReasoningContent || a.ModelName != b.ModelName { - return false - } - return messageMatchesIgnoringReasoning(a, b) +type messageMatchOptions struct { + IgnoreReasoningContent bool + IgnoreModelName bool + IgnoreCreatedAt bool } -func messageMatchesIgnoringReasoning(a, b Message) bool { - if a.ModelName != b.ModelName { - return false - } - return messageMatchesIgnoringReasoningAndModelName(a, b) -} - -func messageMatchesIgnoringReasoningAndModelName(a, b Message) bool { +// messagesMatch compares two messages by role and payload, plus the optional +// metadata fields used by bootstrap repair. TokenCount is intentionally ignored +// because bootstrap may re-estimate it differently. +func messagesMatch(a, b Message, opts messageMatchOptions) bool { if a.Role != b.Role { return false } + if !opts.IgnoreReasoningContent && a.ReasoningContent != b.ReasoningContent { + return false + } + if !opts.IgnoreModelName && a.ModelName != b.ModelName { + return false + } + if !opts.IgnoreCreatedAt && !messageCreatedAtMatches(a.CreatedAt, b.CreatedAt) { + return false + } // If either message has Parts, compare Parts if len(a.Parts) > 0 || len(b.Parts) > 0 { return partsMatch(a.Parts, b.Parts) @@ -705,6 +777,18 @@ func messageMatchesIgnoringReasoningAndModelName(a, b Message) bool { return a.Content == b.Content } +// messageCreatedAtMatches treats missing timestamps as compatible so bootstrap +// can preserve legacy histories while still enforcing exact equality once both +// sides carry canonical created_at values. +func messageCreatedAtMatches(a, b time.Time) bool { + na := normalizeMessageCreatedAt(a) + nb := normalizeMessageCreatedAt(b) + if na.IsZero() || nb.IsZero() { + return true + } + return na.Equal(nb) +} + // partsMatch compares two slices of MessagePart for equality. func partsMatch(a, b []MessagePart) bool { if len(a) != len(b) { diff --git a/pkg/seahorse/short_engine_test.go b/pkg/seahorse/short_engine_test.go index 337416f6f..2e198673d 100644 --- a/pkg/seahorse/short_engine_test.go +++ b/pkg/seahorse/short_engine_test.go @@ -57,8 +57,8 @@ func prepareBootstrapRepairConversation( } return conv, []Message{ - {Role: "user", Content: "hello", TokenCount: 3}, - {Role: "assistant", Content: "world", TokenCount: 3}, + {Role: "user", Content: "hello", TokenCount: 3, CreatedAt: userMsg.CreatedAt}, + {Role: "assistant", Content: "world", TokenCount: 3, CreatedAt: assistantMsg.CreatedAt}, } } @@ -464,13 +464,19 @@ func TestBootstrapRepairsReasoningContentAndModelNameTogether(t *testing.T) { } err = eng.Bootstrap(ctx, sessionKey, []Message{ - {Role: "user", Content: "hello", TokenCount: 3}, + { + Role: "user", + Content: "hello", + TokenCount: 3, + CreatedAt: time.Date(2026, 3, 4, 5, 6, 7, 0, time.UTC), + }, { Role: "assistant", Content: "world", ModelName: "gpt-5.4", ReasoningContent: "let me think this through", TokenCount: 3, + CreatedAt: time.Date(2026, 3, 4, 5, 6, 8, 0, time.UTC), }, }) if err != nil { @@ -515,6 +521,7 @@ func TestBootstrapRepairsIncorrectNonEmptyModelName(t *testing.T) { "wrong-model", "", 3, + time.Time{}, ) if err != nil { t.Fatalf("AddMessageWithReasoning assistant: %v", err) @@ -545,6 +552,64 @@ func TestBootstrapRepairsIncorrectNonEmptyModelName(t *testing.T) { } } +func TestBootstrapRepairsCreatedAt(t *testing.T) { + eng := newTestEngine(t) + ctx := context.Background() + sessionKey := "agent:repair-created-at" + conv, msgs := prepareBootstrapRepairConversation(t, eng, ctx, sessionKey) + + wantCreatedAt := time.Date(2026, 3, 4, 5, 6, 7, 0, time.UTC) + msgs[1].CreatedAt = wantCreatedAt + + err := eng.Bootstrap(ctx, sessionKey, msgs) + if err != nil { + t.Fatalf("Bootstrap: %v", err) + } + + stored, err := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0) + if err != nil { + t.Fatalf("GetMessages: %v", err) + } + if len(stored) != 2 { + t.Fatalf("stored messages = %d, want 2", len(stored)) + } + if !stored[1].CreatedAt.Equal(wantCreatedAt) { + t.Fatalf("stored[1].CreatedAt = %v, want %v", stored[1].CreatedAt, wantCreatedAt) + } +} + +func TestEngineIngestPreservesCreatedAt(t *testing.T) { + eng := newTestEngine(t) + ctx := context.Background() + wantCreatedAt := time.Date(2026, 4, 5, 6, 7, 8, 0, time.UTC) + + msgs := []Message{ + { + Role: "assistant", + Content: "world", + TokenCount: 4, + CreatedAt: wantCreatedAt, + }, + } + + _, err := eng.Ingest(ctx, "agent:created-at", msgs) + if err != nil { + t.Fatalf("Ingest: %v", err) + } + + conv, _ := eng.store.GetOrCreateConversation(ctx, "agent:created-at") + stored, err := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0) + if err != nil { + t.Fatalf("GetMessages: %v", err) + } + if len(stored) != 1 { + t.Fatalf("stored messages = %d, want 1", len(stored)) + } + if !stored[0].CreatedAt.Equal(wantCreatedAt) { + t.Fatalf("stored[0].CreatedAt = %v, want %v", stored[0].CreatedAt, wantCreatedAt) + } +} + func TestEngineIngestWithPartsPreservesReasoningContent(t *testing.T) { eng := newTestEngine(t) ctx := context.Background() @@ -864,8 +929,19 @@ func TestBootstrapRepairsMissingReasoningContentWithoutDroppingSummaries(t *test } err = eng.Bootstrap(ctx, sessionKey, []Message{ - {Role: "user", Content: "hello", TokenCount: 3}, - {Role: "assistant", Content: "world", ReasoningContent: "let me think this through", TokenCount: 3}, + { + Role: "user", + Content: "hello", + TokenCount: 3, + CreatedAt: time.Date(2026, 3, 4, 5, 6, 7, 0, time.UTC), + }, + { + Role: "assistant", + Content: "world", + ReasoningContent: "let me think this through", + TokenCount: 3, + CreatedAt: time.Date(2026, 3, 4, 5, 6, 8, 0, time.UTC), + }, }) if err != nil { t.Fatalf("Bootstrap: %v", err) diff --git a/pkg/seahorse/store.go b/pkg/seahorse/store.go index b5e32e89d..0a0d07044 100644 --- a/pkg/seahorse/store.go +++ b/pkg/seahorse/store.go @@ -8,6 +8,8 @@ import ( "time" ) +const sqliteTimeLayout = "2006-01-02 15:04:05" + // Store provides SQLite storage for seahorse. type Store struct { db *sql.DB @@ -75,8 +77,8 @@ func (s *Store) GetConversationBySessionKey(ctx context.Context, sessionKey stri if err != nil { return nil, fmt.Errorf("get conversation by session key: %w", err) } - conv.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) - conv.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) + conv.CreatedAt = parseSQLiteTime(createdAt) + conv.UpdatedAt = parseSQLiteTime(updatedAt) return &conv, nil } @@ -153,8 +155,8 @@ func (s *Store) getMessageTimeRange(ctx context.Context, convID int64) (time.Tim if err != nil || minTime == "" { return time.Time{}, time.Time{}, err } - oldest, _ := time.Parse("2006-01-02 15:04:05", minTime) - newest, _ := time.Parse("2006-01-02 15:04:05", maxTime) + oldest := parseSQLiteTime(minTime) + newest := parseSQLiteTime(maxTime) return oldest, newest, nil } @@ -162,7 +164,7 @@ func (s *Store) getMessageTimeRange(ctx context.Context, convID int64) (time.Tim // AddMessage appends a message to a conversation. func (s *Store) AddMessage(ctx context.Context, convID int64, role, content string, tokenCount int) (*Message, error) { - return s.AddMessageWithReasoning(ctx, convID, role, content, "", "", tokenCount) + return s.AddMessageWithReasoning(ctx, convID, role, content, "", "", tokenCount, time.Time{}) } // AddMessageWithReasoning appends a message with reasoning content to a conversation. @@ -171,16 +173,22 @@ func (s *Store) AddMessageWithReasoning( convID int64, role, content, modelName, reasoningContent string, tokenCount int, + createdAt time.Time, ) (*Message, error) { + storedCreatedAt := normalizeMessageCreatedAt(createdAt) + if storedCreatedAt.IsZero() { + storedCreatedAt = normalizeMessageCreatedAt(time.Now()) + } result, err := s.db.ExecContext( ctx, - "INSERT INTO messages (conversation_id, role, content, model_name, reasoning_content, token_count) VALUES (?, ?, ?, ?, ?, ?)", + "INSERT INTO messages (conversation_id, role, content, model_name, reasoning_content, token_count, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", convID, role, content, modelName, reasoningContent, tokenCount, + formatSQLiteTime(storedCreatedAt), ) if err != nil { return nil, fmt.Errorf("add message: %w", err) @@ -194,6 +202,7 @@ func (s *Store) AddMessageWithReasoning( ModelName: modelName, ReasoningContent: reasoningContent, TokenCount: tokenCount, + CreatedAt: storedCreatedAt, }, nil } @@ -231,7 +240,7 @@ func (s *Store) AddMessageWithParts( parts []MessagePart, tokenCount int, ) (*Message, error) { - return s.AddMessageWithPartsAndReasoning(ctx, convID, role, parts, "", "", tokenCount) + return s.AddMessageWithPartsAndReasoning(ctx, convID, role, parts, "", "", tokenCount, time.Time{}) } // AddMessageWithPartsAndReasoning adds a message with structured parts and reasoning content. @@ -243,6 +252,7 @@ func (s *Store) AddMessageWithPartsAndReasoning( modelName string, reasoningContent string, tokenCount int, + createdAt time.Time, ) (*Message, error) { tx, err := s.db.BeginTx(ctx, nil) if err != nil { @@ -250,18 +260,24 @@ func (s *Store) AddMessageWithPartsAndReasoning( } defer tx.Rollback() + storedCreatedAt := normalizeMessageCreatedAt(createdAt) + if storedCreatedAt.IsZero() { + storedCreatedAt = normalizeMessageCreatedAt(time.Now()) + } + // Derive readable content from Parts for FTS5 indexing and summary formatting readableContent := partsToReadableContent(parts) result, err := tx.ExecContext( ctx, - "INSERT INTO messages (conversation_id, role, content, model_name, reasoning_content, token_count) VALUES (?, ?, ?, ?, ?, ?)", + "INSERT INTO messages (conversation_id, role, content, model_name, reasoning_content, token_count, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", convID, role, readableContent, modelName, reasoningContent, tokenCount, + formatSQLiteTime(storedCreatedAt), ) if err != nil { return nil, fmt.Errorf("add message: %w", err) @@ -299,6 +315,7 @@ func (s *Store) AddMessageWithPartsAndReasoning( ModelName: modelName, ReasoningContent: reasoningContent, TokenCount: tokenCount, + CreatedAt: storedCreatedAt, Parts: make([]MessagePart, len(parts)), } for i, p := range parts { @@ -344,7 +361,7 @@ func (s *Store) GetMessages(ctx context.Context, convID int64, limit int, before ); err != nil { return nil, err } - msg.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + msg.CreatedAt = parseSQLiteTime(createdAt) msgs = append(msgs, msg) } if err := rows.Err(); err != nil { @@ -387,7 +404,7 @@ func (s *Store) GetMessageByID(ctx context.Context, messageID int64) (*Message, if err != nil { return nil, err } - msg.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + msg.CreatedAt = parseSQLiteTime(createdAt) msg.Parts, _ = s.loadMessageParts(ctx, msg.ID) return &msg, nil } @@ -435,6 +452,32 @@ func (s *Store) UpdateMessageModelName(ctx context.Context, messageID int64, mod return nil } +func (s *Store) UpdateMessageCreatedAt(ctx context.Context, messageID int64, createdAt time.Time) error { + storedCreatedAt := normalizeMessageCreatedAt(createdAt) + if storedCreatedAt.IsZero() { + return fmt.Errorf("message %d created_at cannot be zero", messageID) + } + + result, err := s.db.ExecContext( + ctx, + "UPDATE messages SET created_at = ? WHERE message_id = ?", + formatSQLiteTime(storedCreatedAt), + messageID, + ) + if err != nil { + return fmt.Errorf("update message created_at: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("update message created_at rows affected: %w", err) + } + if rowsAffected == 0 { + return fmt.Errorf("message %d not found", messageID) + } + return nil +} + func (s *Store) loadMessageParts(ctx context.Context, msgID int64) ([]MessagePart, error) { rows, err := s.db.QueryContext(ctx, `SELECT part_id, message_id, type, text, name, arguments, tool_call_id, media_uri, mime_type @@ -648,7 +691,7 @@ func (s *Store) GetSummarySourceMessages(ctx context.Context, summaryID string) ); err != nil { return nil, err } - msg.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + msg.CreatedAt = parseSQLiteTime(createdAt) msgs = append(msgs, msg) } if err := rows.Err(); err != nil { @@ -714,8 +757,7 @@ func (s *Store) GetContextItems(ctx context.Context, convID int64) ([]ContextIte item.MessageID = messageID.Int64 } if createdAt.Valid { - t, _ := time.Parse("2006-01-02 15:04:05", createdAt.String) - item.CreatedAt = t + item.CreatedAt = parseSQLiteTime(createdAt.String) } items = append(items, item) } @@ -1449,7 +1491,7 @@ func (s *Store) scanSearchResults(rows *sql.Rows, withRank bool) ([]SearchResult } } r.Kind = SummaryKind(kind) - r.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + r.CreatedAt = parseSQLiteTime(createdAt) results = append(results, r) } return results, nil @@ -1573,7 +1615,7 @@ func (s *Store) scanMessageSearchResults(rows *sql.Rows, withRank bool) ([]Searc } } r.Snippet = content - r.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + r.CreatedAt = parseSQLiteTime(createdAt) results = append(results, r) } if err := rows.Err(); err != nil { @@ -1606,7 +1648,7 @@ func (s *Store) scanSummary(ctx context.Context, where string, args ...any) (*Su return nil, err } sum.Kind = SummaryKind(kind) - sum.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + sum.CreatedAt = parseSQLiteTime(createdAt) if earliestAt.Valid { t, _ := time.Parse(time.RFC3339, earliestAt.String) sum.EarliestAt = &t @@ -1633,7 +1675,7 @@ func (s *Store) scanSummaries(rows *sql.Rows) ([]Summary, error) { return nil, err } sum.Kind = SummaryKind(kind) - sum.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + sum.CreatedAt = parseSQLiteTime(createdAt) if earliestAt.Valid { t, _ := time.Parse(time.RFC3339, earliestAt.String) sum.EarliestAt = &t @@ -1659,6 +1701,22 @@ func isUniqueViolation(err error) bool { contains(err.Error(), "constraint failed")) } +func normalizeMessageCreatedAt(createdAt time.Time) time.Time { + if createdAt.IsZero() { + return time.Time{} + } + return createdAt.UTC().Truncate(time.Second) +} + +func formatSQLiteTime(t time.Time) string { + return normalizeMessageCreatedAt(t).Format(sqliteTimeLayout) +} + +func parseSQLiteTime(raw string) time.Time { + parsed, _ := time.Parse(sqliteTimeLayout, raw) + return parsed +} + func contains(s, sub string) bool { return len(s) >= len(sub) && searchSubstring(s, sub) } diff --git a/pkg/seahorse/store_test.go b/pkg/seahorse/store_test.go index 4ed2bb3bb..785873150 100644 --- a/pkg/seahorse/store_test.go +++ b/pkg/seahorse/store_test.go @@ -213,6 +213,7 @@ func TestStoreAddAndGetMessagesWithReasoningContent(t *testing.T) { "gpt-5.4-mini", "let me think", 5, + time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC), ) if err != nil { t.Fatalf("AddMessageWithReasoning: %v", err) @@ -223,6 +224,9 @@ func TestStoreAddAndGetMessagesWithReasoningContent(t *testing.T) { if msg.ModelName != "gpt-5.4-mini" { t.Fatalf("ModelName = %q, want %q", msg.ModelName, "gpt-5.4-mini") } + if !msg.CreatedAt.Equal(time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)) { + t.Fatalf("CreatedAt = %v, want 2026-01-02 03:04:05 UTC", msg.CreatedAt) + } msgs, err := s.GetMessages(ctx, conv.ConversationID, 10, 0) if err != nil { @@ -237,6 +241,9 @@ func TestStoreAddAndGetMessagesWithReasoningContent(t *testing.T) { if msgs[0].ModelName != "gpt-5.4-mini" { t.Errorf("ModelName = %q, want %q", msgs[0].ModelName, "gpt-5.4-mini") } + if !msgs[0].CreatedAt.Equal(time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)) { + t.Errorf("CreatedAt = %v, want 2026-01-02 03:04:05 UTC", msgs[0].CreatedAt) + } found, err := s.GetMessageByID(ctx, msg.ID) if err != nil { @@ -248,6 +255,9 @@ func TestStoreAddAndGetMessagesWithReasoningContent(t *testing.T) { if found.ModelName != "gpt-5.4-mini" { t.Errorf("GetMessageByID ModelName = %q, want %q", found.ModelName, "gpt-5.4-mini") } + if !found.CreatedAt.Equal(time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)) { + t.Errorf("GetMessageByID CreatedAt = %v, want 2026-01-02 03:04:05 UTC", found.CreatedAt) + } } func TestStoreAddMessageWithParts(t *testing.T) { @@ -301,6 +311,7 @@ func TestStoreAddMessageWithPartsAndReasoningContent(t *testing.T) { "gpt-5.4", "need to inspect the file first", 10, + time.Date(2026, 2, 3, 4, 5, 6, 0, time.UTC), ) if err != nil { t.Fatalf("AddMessageWithPartsAndReasoning: %v", err) @@ -323,6 +334,9 @@ func TestStoreAddMessageWithPartsAndReasoningContent(t *testing.T) { if msgs[0].ModelName != "gpt-5.4" { t.Errorf("ModelName = %q, want %q", msgs[0].ModelName, "gpt-5.4") } + if !msgs[0].CreatedAt.Equal(time.Date(2026, 2, 3, 4, 5, 6, 0, time.UTC)) { + t.Errorf("CreatedAt = %v, want 2026-02-03 04:05:06 UTC", msgs[0].CreatedAt) + } } func TestStoreGetMessageCount(t *testing.T) { diff --git a/pkg/session/manager.go b/pkg/session/manager.go index 7ca03744d..f75e3e883 100644 --- a/pkg/session/manager.go +++ b/pkg/session/manager.go @@ -60,6 +60,21 @@ func (sm *SessionManager) GetOrCreate(key string) *Session { return session } +func ensureMessageCreatedAt(msg *providers.Message, fallback time.Time) { + if msg.CreatedAt != nil && !msg.CreatedAt.IsZero() { + return + } + ts := fallback + msg.CreatedAt = &ts +} + +func normalizeHistoryCreatedAt(history []providers.Message) { + now := time.Now() + for i := range history { + ensureMessageCreatedAt(&history[i], now) + } +} + func (sm *SessionManager) AddMessage(sessionKey, role, content string) { sm.AddFullMessage(sessionKey, providers.Message{ Role: role, @@ -88,9 +103,7 @@ func (sm *SessionManager) AddFullMessage(sessionKey string, msg providers.Messag } now := time.Now() - if msg.CreatedAt == nil { - msg.CreatedAt = &now - } + ensureMessageCreatedAt(&msg, now) session.Messages = append(session.Messages, msg) session.Updated = now @@ -280,6 +293,7 @@ func (sm *SessionManager) loadSessions() error { continue } session.Messages = messageutil.FilterInvalidHistoryMessages(session.Messages) + normalizeHistoryCreatedAt(session.Messages) sm.sessions[session.Key] = &session } @@ -305,13 +319,8 @@ func (sm *SessionManager) SetHistory(key string, history []providers.Message) { // from the caller's slice. msgs := make([]providers.Message, len(history)) copy(msgs, history) - now := time.Now() - for i := range msgs { - if msgs[i].CreatedAt == nil { - msgs[i].CreatedAt = &now - } - } + normalizeHistoryCreatedAt(msgs) session.Messages = msgs - session.Updated = now + session.Updated = time.Now() } } diff --git a/pkg/session/manager_test.go b/pkg/session/manager_test.go index bc5615966..e167941e7 100644 --- a/pkg/session/manager_test.go +++ b/pkg/session/manager_test.go @@ -83,3 +83,32 @@ func TestSave_RejectsPathTraversal(t *testing.T) { t.Errorf("expected foo_bar.json in storage (sanitized from foo/bar)") } } + +func TestLoadSessions_NormalizesMissingCreatedAt(t *testing.T) { + tmpDir := t.TempDir() + sessionPath := filepath.Join(tmpDir, "telegram_legacy.json") + legacy := `{ + "key": "telegram:legacy", + "messages": [ + { + "role": "user", + "content": "hello" + } + ], + "created": "2026-01-01T00:00:00Z", + "updated": "2026-01-01T00:00:00Z" +}` + + if err := os.WriteFile(sessionPath, []byte(legacy), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + sm := NewSessionManager(tmpDir) + history := sm.GetHistory("telegram:legacy") + if len(history) != 1 { + t.Fatalf("history = %d, want 1", len(history)) + } + if history[0].CreatedAt == nil || history[0].CreatedAt.IsZero() { + t.Fatalf("history[0].CreatedAt = %v, want non-zero timestamp", history[0].CreatedAt) + } +}