From 89e875bd7b1cd80d15313ee0f49a202c5c6d03a5 Mon Sep 17 00:00:00 2001 From: lc6464 <64722907+lc6464@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:36:45 +0800 Subject: [PATCH] fix(seahorse): repair missing reasoning_content during bootstrap --- pkg/seahorse/short_engine.go | 62 +++++++++++++++++++++ pkg/seahorse/short_engine_test.go | 90 +++++++++++++++++++++++++++++++ pkg/seahorse/store.go | 22 ++++++++ pkg/seahorse/store_test.go | 25 +++++++++ 4 files changed, 199 insertions(+) diff --git a/pkg/seahorse/short_engine.go b/pkg/seahorse/short_engine.go index 8f17515a3..d4762d4c9 100644 --- a/pkg/seahorse/short_engine.go +++ b/pkg/seahorse/short_engine.go @@ -445,6 +445,16 @@ func (e *Engine) Bootstrap(ctx context.Context, sessionKey string, messages []Me } } + // Migration repair path: old SeaHorse rows may be missing reasoning_content + // even though the canonical JSONL history already has it. Backfill those + // rows in place so we do not treat this as edited history and leave stale + // summaries/context behind after a partial raw-message rebuild. + if repaired, err := e.repairBootstrapReasoningContent(ctx, dbMsgs, messages); err != nil { + return fmt.Errorf("bootstrap: repair reasoning_content: %w", err) + } else if repaired { + return nil + } + // Find longest matching prefix from the start anchor := -1 compareLen := len(dbMsgs) @@ -538,6 +548,51 @@ func (e *Engine) Bootstrap(ctx context.Context, sessionKey string, messages []Me return nil } +func (e *Engine) repairBootstrapReasoningContent(ctx context.Context, dbMsgs, messages []Message) (bool, error) { + if len(dbMsgs) != len(messages) || len(dbMsgs) == 0 { + return false, nil + } + + var updates []struct { + messageID int64 + reasoningContent string + } + + for i := range messages { + if !messageMatchesIgnoringReasoning(dbMsgs[i], messages[i]) { + return false, nil + } + if dbMsgs[i].ReasoningContent == messages[i].ReasoningContent { + continue + } + if dbMsgs[i].ReasoningContent != "" || messages[i].ReasoningContent == "" { + return false, nil + } + updates = append(updates, struct { + messageID int64 + reasoningContent string + }{ + messageID: dbMsgs[i].ID, + reasoningContent: messages[i].ReasoningContent, + }) + } + + if len(updates) == 0 { + return false, nil + } + + for _, update := range updates { + if err := e.store.UpdateMessageReasoningContent(ctx, update.messageID, update.reasoningContent); err != nil { + return false, err + } + } + + logger.InfoCF("seahorse", "bootstrap: repaired missing reasoning_content", 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 { @@ -555,6 +610,13 @@ func messageMatches(a, b Message) bool { if a.Role != b.Role || a.ReasoningContent != b.ReasoningContent { return false } + return messageMatchesIgnoringReasoning(a, b) +} + +func messageMatchesIgnoringReasoning(a, b Message) bool { + if a.Role != b.Role { + return false + } // If either message has Parts, compare Parts if len(a.Parts) > 0 || len(b.Parts) > 0 { return partsMatch(a.Parts, b.Parts) diff --git a/pkg/seahorse/short_engine_test.go b/pkg/seahorse/short_engine_test.go index 70c08ae9b..afc83cdc3 100644 --- a/pkg/seahorse/short_engine_test.go +++ b/pkg/seahorse/short_engine_test.go @@ -669,6 +669,96 @@ func TestBootstrapRepairsMissingReasoningContent(t *testing.T) { } } +func TestBootstrapRepairsMissingReasoningContentWithoutDroppingSummaries(t *testing.T) { + eng := newTestEngine(t) + ctx := context.Background() + sessionKey := "agent:repair-reasoning-summary" + + conv, err := eng.store.GetOrCreateConversation(ctx, sessionKey) + if err != nil { + t.Fatalf("GetOrCreateConversation: %v", err) + } + + userMsg, err := eng.store.AddMessage(ctx, conv.ConversationID, "user", "hello", 3) + if err != nil { + t.Fatalf("AddMessage user: %v", err) + } + assistantMsg, err := eng.store.AddMessage(ctx, conv.ConversationID, "assistant", "world", 3) + if err != nil { + t.Fatalf("AddMessage assistant: %v", err) + } + + err = eng.store.AppendContextMessages( + ctx, + conv.ConversationID, + []int64{userMsg.ID, assistantMsg.ID}, + ) + if err != nil { + t.Fatalf("AppendContextMessages: %v", err) + } + + summary, err := eng.store.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: conv.ConversationID, + Kind: SummaryKindLeaf, + Depth: 0, + Content: "summary before repair", + TokenCount: 10, + }) + if err != nil { + t.Fatalf("CreateSummary: %v", err) + } + + err = eng.store.AppendContextSummary(ctx, conv.ConversationID, summary.SummaryID) + if err != nil { + t.Fatalf("AppendContextSummary: %v", err) + } + + err = eng.Bootstrap(ctx, sessionKey, []Message{ + {Role: "user", Content: "hello", TokenCount: 3}, + {Role: "assistant", Content: "world", ReasoningContent: "let me think this through", TokenCount: 3}, + }) + 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].ReasoningContent != "let me think this through" { + t.Errorf( + "stored[1].ReasoningContent = %q, want %q", + stored[1].ReasoningContent, + "let me think this through", + ) + } + + summaries, err := eng.store.GetSummariesByConversation(ctx, conv.ConversationID) + if err != nil { + t.Fatalf("GetSummariesByConversation: %v", err) + } + if len(summaries) != 1 { + t.Fatalf("summaries = %d, want 1", len(summaries)) + } + if summaries[0].SummaryID != summary.SummaryID { + t.Errorf("SummaryID = %q, want %q", summaries[0].SummaryID, summary.SummaryID) + } + + items, err := eng.store.GetContextItems(ctx, conv.ConversationID) + if err != nil { + t.Fatalf("GetContextItems: %v", err) + } + if len(items) != 3 { + t.Fatalf("context items = %d, want 3", len(items)) + } + if items[2].ItemType != "summary" || items[2].SummaryID != summary.SummaryID { + t.Errorf("summary context item = %+v, want summary %q", items[2], summary.SummaryID) + } +} + func TestEngineBootstrapDelta(t *testing.T) { eng := newTestEngine(t) ctx := context.Background() diff --git a/pkg/seahorse/store.go b/pkg/seahorse/store.go index b195ca857..0edbbd128 100644 --- a/pkg/seahorse/store.go +++ b/pkg/seahorse/store.go @@ -376,6 +376,28 @@ func (s *Store) GetMessageByID(ctx context.Context, messageID int64) (*Message, return &msg, nil } +// UpdateMessageReasoningContent updates reasoning_content for an existing message. +func (s *Store) UpdateMessageReasoningContent(ctx context.Context, messageID int64, reasoningContent string) error { + result, err := s.db.ExecContext( + ctx, + "UPDATE messages SET reasoning_content = ? WHERE message_id = ?", + reasoningContent, + messageID, + ) + if err != nil { + return fmt.Errorf("update message reasoning_content: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("update message reasoning_content 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 diff --git a/pkg/seahorse/store_test.go b/pkg/seahorse/store_test.go index 2d46a7921..67bed1c11 100644 --- a/pkg/seahorse/store_test.go +++ b/pkg/seahorse/store_test.go @@ -353,6 +353,31 @@ func TestStoreGetMessageByID(t *testing.T) { } } +func TestStoreUpdateMessageReasoningContent(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + conv, _ := s.GetOrCreateConversation(ctx, "agent:update-reasoning") + + msg, err := s.AddMessage(ctx, conv.ConversationID, "assistant", "answer", 3) + if err != nil { + t.Fatalf("AddMessage: %v", err) + } + + err = s.UpdateMessageReasoningContent(ctx, msg.ID, "thinking") + if err != nil { + t.Fatalf("UpdateMessageReasoningContent: %v", err) + } + + found, err := s.GetMessageByID(ctx, msg.ID) + if err != nil { + t.Fatalf("GetMessageByID: %v", err) + } + if found.ReasoningContent != "thinking" { + t.Errorf("ReasoningContent = %q, want %q", found.ReasoningContent, "thinking") + } +} + // --- Summary Operations --- func TestStoreCreateAndGetSummary(t *testing.T) {