fix(seahorse): persist reasoning_content in sqlite history

This commit is contained in:
lc6464 2026-04-29 12:52:54 +08:00
parent db1bc6a1f8
commit ee3c815aff
No known key found for this signature in database
GPG key ID: 53C61B42FEC71D6D
6 changed files with 385 additions and 25 deletions

View file

@ -46,6 +46,7 @@ func runSchema(db *sql.DB) error {
conversation_id INTEGER NOT NULL REFERENCES conversations(conversation_id),
role TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
reasoning_content TEXT NOT NULL DEFAULT '',
token_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
@ -157,9 +158,57 @@ func runSchema(db *sql.DB) error {
return err
}
}
if err := ensureMessagesReasoningContentColumn(db); err != nil {
return err
}
return nil
}
func ensureMessagesReasoningContentColumn(db *sql.DB) error {
hasColumn, err := tableHasColumn(db, "messages", "reasoning_content")
if err != nil {
return fmt.Errorf("check messages.reasoning_content: %w", err)
}
if hasColumn {
return nil
}
if _, err := db.Exec(`ALTER TABLE messages ADD COLUMN reasoning_content TEXT NOT NULL DEFAULT ''`); err != nil {
return fmt.Errorf("add messages.reasoning_content: %w", err)
}
return nil
}
func tableHasColumn(db *sql.DB, tableName, columnName string) (bool, error) {
rows, err := db.Query(fmt.Sprintf(`PRAGMA table_info(%s)`, tableName))
if err != nil {
return false, err
}
defer rows.Close()
for rows.Next() {
var (
cid int
name string
columnType string
notNull int
defaultVal sql.NullString
pk int
)
if err := rows.Scan(&cid, &name, &columnType, &notNull, &defaultVal, &pk); err != nil {
return false, err
}
if name == columnName {
return true, nil
}
}
if err := rows.Err(); err != nil {
return false, err
}
return false, nil
}
// checkFTS5Support verifies that SQLite has FTS5 with trigram tokenizer enabled.
// This is required for full-text search with CJK (Chinese, Japanese, Korean) support.
func checkFTS5Support(db *sql.DB) error {

View file

@ -91,6 +91,51 @@ func TestRunMigrationsIdempotent(t *testing.T) {
}
}
func TestRunSchemaAddsMessagesReasoningContentColumn(t *testing.T) {
db := openTestDB(t)
_, err := db.Exec(`CREATE TABLE messages (
message_id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id INTEGER NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
token_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`)
if err != nil {
t.Fatalf("create legacy messages table: %v", err)
}
if err := runSchema(db); err != nil {
t.Fatalf("runSchema: %v", err)
}
var count int
err = db.QueryRow(`SELECT count(*) FROM pragma_table_info('messages') WHERE name = 'reasoning_content'`).Scan(&count)
if err != nil {
t.Fatalf("query pragma_table_info: %v", err)
}
if count != 1 {
t.Fatalf("reasoning_content column count = %d, want 1", count)
}
_, err = db.Exec(
`INSERT INTO conversations (session_key, created_at, updated_at) VALUES (?, datetime('now'), datetime('now'))`,
"reasoning-column-test",
)
if err != nil {
t.Fatalf("insert conversation: %v", err)
}
_, err = db.Exec(
`INSERT INTO messages (conversation_id, role, content, reasoning_content, token_count)
VALUES (1, 'assistant', 'answer', 'thinking', 1)`,
)
if err != nil {
t.Fatalf("insert message with reasoning_content: %v", err)
}
}
func TestMigrationConversationUnique(t *testing.T) {
db := openTestDB(t)
if err := runSchema(db); err != nil {

View file

@ -253,9 +253,23 @@ func (e *Engine) Ingest(ctx context.Context, sessionKey string, messages []Messa
var added *Message
var err error
if len(msg.Parts) > 0 {
added, err = e.store.AddMessageWithParts(ctx, conv.ConversationID, msg.Role, msg.Parts, msg.TokenCount)
added, err = e.store.AddMessageWithPartsAndReasoning(
ctx,
conv.ConversationID,
msg.Role,
msg.Parts,
msg.ReasoningContent,
msg.TokenCount,
)
} else {
added, err = e.store.AddMessage(ctx, conv.ConversationID, msg.Role, msg.Content, msg.TokenCount)
added, err = e.store.AddMessageWithReasoning(
ctx,
conv.ConversationID,
msg.Role,
msg.Content,
msg.ReasoningContent,
msg.TokenCount,
)
}
if err != nil {
return nil, fmt.Errorf("add message: %w", err)
@ -532,13 +546,13 @@ func truncate(s string, maxLen int) string {
return s[:maxLen] + "..."
}
// messageMatches compares two messages using (role, content) or (role, parts).
// TokenCount is NOT compared because it may be re-estimated differently
// during bootstrap (e.g., via tokenizer.EstimateMessageTokens).
// 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
// since AddMessageWithParts stores empty Content in DB.
// because structured messages are matched by their parts payload.
func messageMatches(a, b Message) bool {
if a.Role != b.Role {
if a.Role != b.Role || a.ReasoningContent != b.ReasoningContent {
return false
}
// If either message has Parts, compare Parts

View file

@ -320,6 +320,108 @@ func TestEngineIngestWithParts(t *testing.T) {
}
}
func TestEngineIngestPreservesReasoningContent(t *testing.T) {
eng := newTestEngine(t)
ctx := context.Background()
msgs := []Message{
{
Role: "assistant",
Content: "world",
ReasoningContent: "let me think this through",
TokenCount: 4,
},
}
_, err := eng.Ingest(ctx, "agent:reasoning", msgs)
if err != nil {
t.Fatalf("Ingest: %v", err)
}
conv, _ := eng.store.GetOrCreateConversation(ctx, "agent:reasoning")
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].ReasoningContent != "let me think this through" {
t.Errorf(
"stored[0].ReasoningContent = %q, want %q",
stored[0].ReasoningContent,
"let me think this through",
)
}
result, err := eng.Assemble(ctx, "agent:reasoning", AssembleInput{Budget: 1000})
if err != nil {
t.Fatalf("Assemble: %v", err)
}
if len(result.Messages) != 1 {
t.Fatalf("assembled messages = %d, want 1", len(result.Messages))
}
if result.Messages[0].ReasoningContent != "let me think this through" {
t.Errorf(
"assembled reasoning = %q, want %q",
result.Messages[0].ReasoningContent,
"let me think this through",
)
}
}
func TestEngineIngestWithPartsPreservesReasoningContent(t *testing.T) {
eng := newTestEngine(t)
ctx := context.Background()
msgs := []Message{
{
Role: "assistant",
ReasoningContent: "I need to inspect the file first",
TokenCount: 10,
Parts: []MessagePart{
{Type: "tool_use", Name: "read_file", Arguments: `{"path":"/tmp/test"}`, ToolCallID: "tc_123"},
},
},
}
_, err := eng.Ingest(ctx, "agent:parts-reasoning", msgs)
if err != nil {
t.Fatalf("Ingest: %v", err)
}
conv, _ := eng.store.GetOrCreateConversation(ctx, "agent:parts-reasoning")
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].ReasoningContent != "I need to inspect the file first" {
t.Errorf(
"stored reasoning = %q, want %q",
stored[0].ReasoningContent,
"I need to inspect the file first",
)
}
result, err := eng.Assemble(ctx, "agent:parts-reasoning", AssembleInput{Budget: 1000})
if err != nil {
t.Fatalf("Assemble: %v", err)
}
if len(result.Messages) != 1 {
t.Fatalf("assembled messages = %d, want 1", len(result.Messages))
}
if result.Messages[0].ReasoningContent != "I need to inspect the file first" {
t.Errorf(
"assembled reasoning = %q, want %q",
result.Messages[0].ReasoningContent,
"I need to inspect the file first",
)
}
}
func TestEngineIngestAssemblePreservesParts(t *testing.T) {
eng := newTestEngine(t)
ctx := context.Background()
@ -514,6 +616,52 @@ func TestEngineBootstrapIdempotent(t *testing.T) {
}
}
func TestBootstrapRepairsMissingReasoningContent(t *testing.T) {
eng := newTestEngine(t)
ctx := context.Background()
sessionKey := "agent:repair-reasoning"
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)
}
if err := eng.store.AppendContextMessages(ctx, conv.ConversationID, []int64{userMsg.ID, assistantMsg.ID}); err != nil {
t.Fatalf("AppendContextMessages: %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",
)
}
}
func TestEngineBootstrapDelta(t *testing.T) {
eng := newTestEngine(t)
ctx := context.Background()

View file

@ -162,20 +162,31 @@ 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)
}
// AddMessageWithReasoning appends a message with reasoning content to a conversation.
func (s *Store) AddMessageWithReasoning(
ctx context.Context,
convID int64,
role, content, reasoningContent string,
tokenCount int,
) (*Message, error) {
result, err := s.db.ExecContext(ctx,
"INSERT INTO messages (conversation_id, role, content, token_count) VALUES (?, ?, ?, ?)",
convID, role, content, tokenCount,
"INSERT INTO messages (conversation_id, role, content, reasoning_content, token_count) VALUES (?, ?, ?, ?, ?)",
convID, role, content, reasoningContent, tokenCount,
)
if err != nil {
return nil, fmt.Errorf("add message: %w", err)
}
id, _ := result.LastInsertId()
return &Message{
ID: id,
ConversationID: convID,
Role: role,
Content: content,
TokenCount: tokenCount,
ID: id,
ConversationID: convID,
Role: role,
Content: content,
ReasoningContent: reasoningContent,
TokenCount: tokenCount,
}, nil
}
@ -212,6 +223,18 @@ func (s *Store) AddMessageWithParts(
role string,
parts []MessagePart,
tokenCount int,
) (*Message, error) {
return s.AddMessageWithPartsAndReasoning(ctx, convID, role, parts, "", tokenCount)
}
// AddMessageWithPartsAndReasoning adds a message with structured parts and reasoning content.
func (s *Store) AddMessageWithPartsAndReasoning(
ctx context.Context,
convID int64,
role string,
parts []MessagePart,
reasoningContent string,
tokenCount int,
) (*Message, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
@ -223,8 +246,8 @@ func (s *Store) AddMessageWithParts(
readableContent := partsToReadableContent(parts)
result, err := tx.ExecContext(ctx,
"INSERT INTO messages (conversation_id, role, content, token_count) VALUES (?, ?, ?, ?)",
convID, role, readableContent, tokenCount,
"INSERT INTO messages (conversation_id, role, content, reasoning_content, token_count) VALUES (?, ?, ?, ?, ?)",
convID, role, readableContent, reasoningContent, tokenCount,
)
if err != nil {
return nil, fmt.Errorf("add message: %w", err)
@ -256,11 +279,12 @@ func (s *Store) AddMessageWithParts(
// Return message with parts
msg := &Message{
ID: msgID,
ConversationID: convID,
Role: role,
TokenCount: tokenCount,
Parts: make([]MessagePart, len(parts)),
ID: msgID,
ConversationID: convID,
Role: role,
ReasoningContent: reasoningContent,
TokenCount: tokenCount,
Parts: make([]MessagePart, len(parts)),
}
for i, p := range parts {
p.MessageID = msgID
@ -271,7 +295,7 @@ func (s *Store) AddMessageWithParts(
// GetMessages retrieves messages for a conversation.
func (s *Store) GetMessages(ctx context.Context, convID int64, limit int, beforeID int64) ([]Message, error) {
query := "SELECT message_id, conversation_id, role, content, token_count, created_at FROM messages WHERE conversation_id = ?"
query := "SELECT message_id, conversation_id, role, content, reasoning_content, token_count, created_at FROM messages WHERE conversation_id = ?"
args := []any{convID}
if beforeID > 0 {
query += " AND message_id < ?"
@ -298,6 +322,7 @@ func (s *Store) GetMessages(ctx context.Context, convID int64, limit int, before
&msg.ConversationID,
&msg.Role,
&msg.Content,
&msg.ReasoningContent,
&msg.TokenCount,
&createdAt,
); err != nil {
@ -336,9 +361,9 @@ func (s *Store) GetMessageByID(ctx context.Context, messageID int64) (*Message,
var msg Message
var createdAt string
err := s.db.QueryRowContext(ctx,
"SELECT message_id, conversation_id, role, content, token_count, created_at FROM messages WHERE message_id = ?",
"SELECT message_id, conversation_id, role, content, reasoning_content, token_count, created_at FROM messages WHERE message_id = ?",
messageID,
).Scan(&msg.ID, &msg.ConversationID, &msg.Role, &msg.Content, &msg.TokenCount, &createdAt)
).Scan(&msg.ID, &msg.ConversationID, &msg.Role, &msg.Content, &msg.ReasoningContent, &msg.TokenCount, &createdAt)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("message %d not found", messageID)
}
@ -534,7 +559,7 @@ func (s *Store) LinkSummaryToMessages(ctx context.Context, summaryID string, mes
// GetSummarySourceMessages retrieves source messages for a summary.
func (s *Store) GetSummarySourceMessages(ctx context.Context, summaryID string) ([]Message, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT m.message_id, m.conversation_id, m.role, m.content, m.token_count, m.created_at
`SELECT m.message_id, m.conversation_id, m.role, m.content, m.reasoning_content, m.token_count, m.created_at
FROM summary_messages sm
JOIN messages m ON m.message_id = sm.message_id
WHERE sm.summary_id = ?
@ -555,6 +580,7 @@ func (s *Store) GetSummarySourceMessages(ctx context.Context, summaryID string)
&msg.ConversationID,
&msg.Role,
&msg.Content,
&msg.ReasoningContent,
&msg.TokenCount,
&createdAt,
); err != nil {

View file

@ -199,6 +199,47 @@ func TestStoreAddAndGetMessages(t *testing.T) {
}
}
func TestStoreAddAndGetMessagesWithReasoningContent(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
conv, _ := s.GetOrCreateConversation(ctx, "agent:reasoning")
msg, err := s.AddMessageWithReasoning(
ctx,
conv.ConversationID,
"assistant",
"hello world",
"let me think",
5,
)
if err != nil {
t.Fatalf("AddMessageWithReasoning: %v", err)
}
if msg.ReasoningContent != "let me think" {
t.Fatalf("ReasoningContent = %q, want %q", msg.ReasoningContent, "let me think")
}
msgs, err := s.GetMessages(ctx, conv.ConversationID, 10, 0)
if err != nil {
t.Fatalf("GetMessages: %v", err)
}
if len(msgs) != 1 {
t.Fatalf("got %d messages, want 1", len(msgs))
}
if msgs[0].ReasoningContent != "let me think" {
t.Errorf("ReasoningContent = %q, want %q", msgs[0].ReasoningContent, "let me think")
}
found, err := s.GetMessageByID(ctx, msg.ID)
if err != nil {
t.Fatalf("GetMessageByID: %v", err)
}
if found.ReasoningContent != "let me think" {
t.Errorf("GetMessageByID ReasoningContent = %q, want %q", found.ReasoningContent, "let me think")
}
}
func TestStoreAddMessageWithParts(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
@ -233,6 +274,43 @@ func TestStoreAddMessageWithParts(t *testing.T) {
}
}
func TestStoreAddMessageWithPartsAndReasoningContent(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
conv, _ := s.GetOrCreateConversation(ctx, "agent:parts-reasoning")
parts := []MessagePart{
{Type: "tool_use", Name: "read_file", Arguments: `{"path":"/tmp/test"}`, ToolCallID: "tc_123"},
}
_, err := s.AddMessageWithPartsAndReasoning(
ctx,
conv.ConversationID,
"assistant",
parts,
"need to inspect the file first",
10,
)
if err != nil {
t.Fatalf("AddMessageWithPartsAndReasoning: %v", err)
}
msgs, err := s.GetMessages(ctx, conv.ConversationID, 10, 0)
if err != nil {
t.Fatalf("GetMessages: %v", err)
}
if len(msgs) != 1 {
t.Fatalf("expected 1 message, got %d", len(msgs))
}
if msgs[0].ReasoningContent != "need to inspect the file first" {
t.Errorf(
"ReasoningContent = %q, want %q",
msgs[0].ReasoningContent,
"need to inspect the file first",
)
}
}
func TestStoreGetMessageCount(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()