mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
Merge pull request #2946 from lc6464/feat/seahorse-created-at-history
fix(seahorse,session): preserve created_at across history bootstrap
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+108
-24
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
+75
-17
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+19
-10
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user