fix(seahorse): persist reasoning_content in sqlite history (#2707)
* fix(seahorse): persist reasoning_content in sqlite history * fix(openai_compat): clarify DeepSeek reasoning replay rules * style(seahorse): format files of seahorse * fix(openai_compat): cover DeepSeek replay requirements * fix(seahorse): repair missing reasoning_content during bootstrap * fix(seahorse): repair reasoning_content on bootstrap prefix
This commit is contained in:
parent
5db008f384
commit
dbf5d9ce1f
8 changed files with 781 additions and 32 deletions
|
|
@ -270,6 +270,10 @@ func filterDeepSeekReasoningTurn(messages []Message) []Message {
|
||||||
}
|
}
|
||||||
|
|
||||||
cloned := msg
|
cloned := msg
|
||||||
|
// DeepSeek thinking-mode replay only requires reasoning_content for
|
||||||
|
// turns that participate in a tool interaction round. For plain
|
||||||
|
// assistant turns between two user messages, the docs say the API will
|
||||||
|
// ignore reasoning_content on replay, so we strip it here.
|
||||||
if cloned.Role == "assistant" && strings.TrimSpace(cloned.ReasoningContent) != "" && !hasToolInteraction {
|
if cloned.Role == "assistant" && strings.TrimSpace(cloned.ReasoningContent) != "" && !hasToolInteraction {
|
||||||
cloned.ReasoningContent = ""
|
cloned.ReasoningContent = ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -526,6 +526,112 @@ func TestProviderChat_HistoryCanonicalizationMatrix(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProviderChat_DeepSeekDocsReplayRequirements(t *testing.T) {
|
||||||
|
// DeepSeek's thinking-mode and multi-round chat docs distinguish two cases:
|
||||||
|
// - for a plain assistant turn between two user messages without tool calls,
|
||||||
|
// reasoning_content does not need to be replayed and the API ignores it if sent;
|
||||||
|
// - for a turn that participates in a tool-interaction round, assistant
|
||||||
|
// reasoning_content must be replayed on subsequent requests.
|
||||||
|
//
|
||||||
|
// Keep this behavior explicit here so future changes do not "fix" the
|
||||||
|
// non-tool stripping based on issue reports that are broader than the
|
||||||
|
// vendor documentation.
|
||||||
|
var requestBody map[string]any
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp := map[string]any{
|
||||||
|
"choices": []map[string]any{
|
||||||
|
{
|
||||||
|
"message": map[string]any{"content": "ok"},
|
||||||
|
"finish_reason": "stop",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
p := NewProvider("key", server.URL, "")
|
||||||
|
p.SetProviderName("deepseek")
|
||||||
|
|
||||||
|
messages := []Message{
|
||||||
|
{Role: "user", Content: "Who wrote The Hobbit?"},
|
||||||
|
{Role: "assistant", Content: "J.R.R. Tolkien.", ReasoningContent: "I know this from general knowledge."},
|
||||||
|
{Role: "user", Content: "What's the weather tomorrow?"},
|
||||||
|
{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: "Let me check the date first.",
|
||||||
|
ReasoningContent: "I need tomorrow's date before checking the weather.",
|
||||||
|
ToolCalls: []ToolCall{{
|
||||||
|
ID: "call_date",
|
||||||
|
Type: "function",
|
||||||
|
Function: &FunctionCall{
|
||||||
|
Name: "get_date",
|
||||||
|
Arguments: "{}",
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
{Role: "tool", ToolCallID: "call_date", Content: "2026-04-29"},
|
||||||
|
{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: "Tomorrow is 2026-04-30.",
|
||||||
|
ReasoningContent: "Now I can continue with the weather request.",
|
||||||
|
},
|
||||||
|
{Role: "user", Content: "What about Guangzhou?"},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := p.Chat(t.Context(), messages, nil, "deepseek-v4-flash", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chat() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reqMessages, ok := requestBody["messages"].([]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("messages is not []any: %T", requestBody["messages"])
|
||||||
|
}
|
||||||
|
if len(reqMessages) != len(messages) {
|
||||||
|
t.Fatalf("len(messages) = %d, want %d", len(reqMessages), len(messages))
|
||||||
|
}
|
||||||
|
|
||||||
|
plainAssistant, ok := reqMessages[1].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("plain assistant message is not map[string]any: %T", reqMessages[1])
|
||||||
|
}
|
||||||
|
if _, exists := plainAssistant["reasoning_content"]; exists {
|
||||||
|
t.Fatalf(
|
||||||
|
"plain DeepSeek turn should omit reasoning_content on replay, got %v",
|
||||||
|
plainAssistant["reasoning_content"],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
toolAssistant, ok := reqMessages[3].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("tool assistant message is not map[string]any: %T", reqMessages[3])
|
||||||
|
}
|
||||||
|
if toolAssistant["reasoning_content"] != "I need tomorrow's date before checking the weather." {
|
||||||
|
t.Fatalf(
|
||||||
|
"tool assistant reasoning_content = %v, want preserved",
|
||||||
|
toolAssistant["reasoning_content"],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
finalAssistant, ok := reqMessages[5].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("final assistant message is not map[string]any: %T", reqMessages[5])
|
||||||
|
}
|
||||||
|
if finalAssistant["reasoning_content"] != "Now I can continue with the weather request." {
|
||||||
|
t.Fatalf(
|
||||||
|
"final assistant reasoning_content = %v, want preserved",
|
||||||
|
finalAssistant["reasoning_content"],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestProviderChat_HTTPError(t *testing.T) {
|
func TestProviderChat_HTTPError(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
http.Error(w, "bad request", http.StatusBadRequest)
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ func runSchema(db *sql.DB) error {
|
||||||
conversation_id INTEGER NOT NULL REFERENCES conversations(conversation_id),
|
conversation_id INTEGER NOT NULL REFERENCES conversations(conversation_id),
|
||||||
role TEXT NOT NULL,
|
role TEXT NOT NULL,
|
||||||
content TEXT NOT NULL DEFAULT '',
|
content TEXT NOT NULL DEFAULT '',
|
||||||
|
reasoning_content TEXT NOT NULL DEFAULT '',
|
||||||
token_count INTEGER NOT NULL DEFAULT 0,
|
token_count INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
)`,
|
)`,
|
||||||
|
|
@ -157,9 +158,57 @@ func runSchema(db *sql.DB) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := ensureMessagesReasoningContentColumn(db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
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, ¬Null, &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.
|
// checkFTS5Support verifies that SQLite has FTS5 with trigram tokenizer enabled.
|
||||||
// This is required for full-text search with CJK (Chinese, Japanese, Korean) support.
|
// This is required for full-text search with CJK (Chinese, Japanese, Korean) support.
|
||||||
func checkFTS5Support(db *sql.DB) error {
|
func checkFTS5Support(db *sql.DB) error {
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,53 @@ 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = runSchema(db)
|
||||||
|
if 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) {
|
func TestMigrationConversationUnique(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
if err := runSchema(db); err != nil {
|
if err := runSchema(db); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -253,9 +253,23 @@ func (e *Engine) Ingest(ctx context.Context, sessionKey string, messages []Messa
|
||||||
var added *Message
|
var added *Message
|
||||||
var err error
|
var err error
|
||||||
if len(msg.Parts) > 0 {
|
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 {
|
} 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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("add message: %w", err)
|
return nil, fmt.Errorf("add message: %w", err)
|
||||||
|
|
@ -420,7 +434,7 @@ func (e *Engine) Bootstrap(ctx context.Context, sessionKey string, messages []Me
|
||||||
// Fast path: DB has same count and exact match → no-op
|
// Fast path: DB has same count and exact match → no-op
|
||||||
if len(dbMsgs) == len(messages) {
|
if len(dbMsgs) == len(messages) {
|
||||||
matched := true
|
matched := true
|
||||||
for i := 0; i < len(messages); i++ {
|
for i := range messages {
|
||||||
if !messageMatches(dbMsgs[i], messages[i]) {
|
if !messageMatches(dbMsgs[i], messages[i]) {
|
||||||
matched = false
|
matched = false
|
||||||
break
|
break
|
||||||
|
|
@ -431,14 +445,21 @@ func (e *Engine) Bootstrap(ctx context.Context, sessionKey string, messages []Me
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find longest matching prefix from the start
|
// Migration repair path: old SeaHorse rows may be missing reasoning_content
|
||||||
anchor := -1
|
// even though the canonical JSONL history already has it. Backfill those
|
||||||
compareLen := len(dbMsgs)
|
// rows in place so we do not treat this as edited history and leave stale
|
||||||
if compareLen > len(messages) {
|
// summaries/context behind after a partial raw-message rebuild.
|
||||||
compareLen = len(messages)
|
if repaired, err := e.repairBootstrapReasoningContent(ctx, dbMsgs, messages); err != nil {
|
||||||
|
return fmt.Errorf("bootstrap: repair reasoning_content: %w", err)
|
||||||
|
} else if repaired && len(dbMsgs) == len(messages) {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := 0; i < compareLen; i++ {
|
// Find longest matching prefix from the start
|
||||||
|
anchor := -1
|
||||||
|
compareLen := min(len(dbMsgs), len(messages))
|
||||||
|
|
||||||
|
for i := range compareLen {
|
||||||
if messageMatches(dbMsgs[i], messages[i]) {
|
if messageMatches(dbMsgs[i], messages[i]) {
|
||||||
anchor = i
|
anchor = i
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -524,6 +545,57 @@ func (e *Engine) Bootstrap(ctx context.Context, sessionKey string, messages []Me
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *Engine) repairBootstrapReasoningContent(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
|
||||||
|
reasoningContent string
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range overlap {
|
||||||
|
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 {
|
||||||
|
index int
|
||||||
|
messageID int64
|
||||||
|
reasoningContent string
|
||||||
|
}{
|
||||||
|
index: i,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
dbMsgs[update.index].ReasoningContent = update.reasoningContent
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.InfoCF("seahorse", "bootstrap: repaired missing reasoning_content", map[string]any{
|
||||||
|
"messages": len(updates),
|
||||||
|
})
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
// truncate shortens a string for logging.
|
// truncate shortens a string for logging.
|
||||||
func truncate(s string, maxLen int) string {
|
func truncate(s string, maxLen int) string {
|
||||||
if len(s) <= maxLen {
|
if len(s) <= maxLen {
|
||||||
|
|
@ -532,12 +604,19 @@ func truncate(s string, maxLen int) string {
|
||||||
return s[:maxLen] + "..."
|
return s[:maxLen] + "..."
|
||||||
}
|
}
|
||||||
|
|
||||||
// messageMatches compares two messages using (role, content) or (role, parts).
|
// messageMatches compares two messages using role + reasoning_content and then
|
||||||
// TokenCount is NOT compared because it may be re-estimated differently
|
// either content or parts. TokenCount is NOT compared because it may be
|
||||||
// during bootstrap (e.g., via tokenizer.EstimateMessageTokens).
|
// re-estimated differently during bootstrap (e.g., via tokenizer.EstimateMessageTokens).
|
||||||
// For messages with Parts (tool_use, tool_result), compare Parts instead of Content
|
// 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 {
|
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 {
|
if a.Role != b.Role {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestEngineIngestAssemblePreservesParts(t *testing.T) {
|
||||||
eng := newTestEngine(t)
|
eng := newTestEngine(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
@ -514,6 +616,216 @@ 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = eng.store.AppendContextMessages(
|
||||||
|
ctx,
|
||||||
|
conv.ConversationID,
|
||||||
|
[]int64{userMsg.ID, assistantMsg.ID},
|
||||||
|
)
|
||||||
|
if 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 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 TestBootstrapRepairsMissingReasoningContentOnPrefixBeforeAppendingDelta(t *testing.T) {
|
||||||
|
eng := newTestEngine(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
sessionKey := "agent:repair-reasoning-prefix"
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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: "follow-up", TokenCount: 2},
|
||||||
|
})
|
||||||
|
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) != 3 {
|
||||||
|
t.Fatalf("stored messages = %d, want 3", 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",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if stored[2].Content != "follow-up" {
|
||||||
|
t.Errorf("stored[2].Content = %q, want %q", stored[2].Content, "follow-up")
|
||||||
|
}
|
||||||
|
|
||||||
|
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 != "message" || items[2].MessageID != stored[2].ID {
|
||||||
|
t.Errorf("last context item = %+v, want appended message %d", items[2], stored[2].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEngineBootstrapDelta(t *testing.T) {
|
func TestEngineBootstrapDelta(t *testing.T) {
|
||||||
eng := newTestEngine(t)
|
eng := newTestEngine(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
|
||||||
|
|
@ -162,20 +162,31 @@ func (s *Store) getMessageTimeRange(ctx context.Context, convID int64) (time.Tim
|
||||||
|
|
||||||
// AddMessage appends a message to a conversation.
|
// AddMessage appends a message to a conversation.
|
||||||
func (s *Store) AddMessage(ctx context.Context, convID int64, role, content string, tokenCount int) (*Message, error) {
|
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,
|
result, err := s.db.ExecContext(ctx,
|
||||||
"INSERT INTO messages (conversation_id, role, content, token_count) VALUES (?, ?, ?, ?)",
|
"INSERT INTO messages (conversation_id, role, content, reasoning_content, token_count) VALUES (?, ?, ?, ?, ?)",
|
||||||
convID, role, content, tokenCount,
|
convID, role, content, reasoningContent, tokenCount,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("add message: %w", err)
|
return nil, fmt.Errorf("add message: %w", err)
|
||||||
}
|
}
|
||||||
id, _ := result.LastInsertId()
|
id, _ := result.LastInsertId()
|
||||||
return &Message{
|
return &Message{
|
||||||
ID: id,
|
ID: id,
|
||||||
ConversationID: convID,
|
ConversationID: convID,
|
||||||
Role: role,
|
Role: role,
|
||||||
Content: content,
|
Content: content,
|
||||||
TokenCount: tokenCount,
|
ReasoningContent: reasoningContent,
|
||||||
|
TokenCount: tokenCount,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -212,6 +223,18 @@ func (s *Store) AddMessageWithParts(
|
||||||
role string,
|
role string,
|
||||||
parts []MessagePart,
|
parts []MessagePart,
|
||||||
tokenCount int,
|
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) {
|
) (*Message, error) {
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -223,8 +246,8 @@ func (s *Store) AddMessageWithParts(
|
||||||
readableContent := partsToReadableContent(parts)
|
readableContent := partsToReadableContent(parts)
|
||||||
|
|
||||||
result, err := tx.ExecContext(ctx,
|
result, err := tx.ExecContext(ctx,
|
||||||
"INSERT INTO messages (conversation_id, role, content, token_count) VALUES (?, ?, ?, ?)",
|
"INSERT INTO messages (conversation_id, role, content, reasoning_content, token_count) VALUES (?, ?, ?, ?, ?)",
|
||||||
convID, role, readableContent, tokenCount,
|
convID, role, readableContent, reasoningContent, tokenCount,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("add message: %w", err)
|
return nil, fmt.Errorf("add message: %w", err)
|
||||||
|
|
@ -256,11 +279,12 @@ func (s *Store) AddMessageWithParts(
|
||||||
|
|
||||||
// Return message with parts
|
// Return message with parts
|
||||||
msg := &Message{
|
msg := &Message{
|
||||||
ID: msgID,
|
ID: msgID,
|
||||||
ConversationID: convID,
|
ConversationID: convID,
|
||||||
Role: role,
|
Role: role,
|
||||||
TokenCount: tokenCount,
|
ReasoningContent: reasoningContent,
|
||||||
Parts: make([]MessagePart, len(parts)),
|
TokenCount: tokenCount,
|
||||||
|
Parts: make([]MessagePart, len(parts)),
|
||||||
}
|
}
|
||||||
for i, p := range parts {
|
for i, p := range parts {
|
||||||
p.MessageID = msgID
|
p.MessageID = msgID
|
||||||
|
|
@ -271,7 +295,7 @@ func (s *Store) AddMessageWithParts(
|
||||||
|
|
||||||
// GetMessages retrieves messages for a conversation.
|
// GetMessages retrieves messages for a conversation.
|
||||||
func (s *Store) GetMessages(ctx context.Context, convID int64, limit int, beforeID int64) ([]Message, error) {
|
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}
|
args := []any{convID}
|
||||||
if beforeID > 0 {
|
if beforeID > 0 {
|
||||||
query += " AND message_id < ?"
|
query += " AND message_id < ?"
|
||||||
|
|
@ -298,6 +322,7 @@ func (s *Store) GetMessages(ctx context.Context, convID int64, limit int, before
|
||||||
&msg.ConversationID,
|
&msg.ConversationID,
|
||||||
&msg.Role,
|
&msg.Role,
|
||||||
&msg.Content,
|
&msg.Content,
|
||||||
|
&msg.ReasoningContent,
|
||||||
&msg.TokenCount,
|
&msg.TokenCount,
|
||||||
&createdAt,
|
&createdAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
|
|
@ -335,10 +360,11 @@ func (s *Store) GetMessageCount(ctx context.Context, convID int64) (int, error)
|
||||||
func (s *Store) GetMessageByID(ctx context.Context, messageID int64) (*Message, error) {
|
func (s *Store) GetMessageByID(ctx context.Context, messageID int64) (*Message, error) {
|
||||||
var msg Message
|
var msg Message
|
||||||
var createdAt string
|
var createdAt string
|
||||||
err := s.db.QueryRowContext(ctx,
|
err := s.db.QueryRowContext(
|
||||||
"SELECT message_id, conversation_id, role, content, token_count, created_at FROM messages WHERE message_id = ?",
|
ctx,
|
||||||
|
"SELECT message_id, conversation_id, role, content, reasoning_content, token_count, created_at FROM messages WHERE message_id = ?",
|
||||||
messageID,
|
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 {
|
if err == sql.ErrNoRows {
|
||||||
return nil, fmt.Errorf("message %d not found", messageID)
|
return nil, fmt.Errorf("message %d not found", messageID)
|
||||||
}
|
}
|
||||||
|
|
@ -350,6 +376,28 @@ func (s *Store) GetMessageByID(ctx context.Context, messageID int64) (*Message,
|
||||||
return &msg, nil
|
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) {
|
func (s *Store) loadMessageParts(ctx context.Context, msgID int64) ([]MessagePart, error) {
|
||||||
rows, err := s.db.QueryContext(ctx,
|
rows, err := s.db.QueryContext(ctx,
|
||||||
`SELECT part_id, message_id, type, text, name, arguments, tool_call_id, media_uri, mime_type
|
`SELECT part_id, message_id, type, text, name, arguments, tool_call_id, media_uri, mime_type
|
||||||
|
|
@ -534,7 +582,7 @@ func (s *Store) LinkSummaryToMessages(ctx context.Context, summaryID string, mes
|
||||||
// GetSummarySourceMessages retrieves source messages for a summary.
|
// GetSummarySourceMessages retrieves source messages for a summary.
|
||||||
func (s *Store) GetSummarySourceMessages(ctx context.Context, summaryID string) ([]Message, error) {
|
func (s *Store) GetSummarySourceMessages(ctx context.Context, summaryID string) ([]Message, error) {
|
||||||
rows, err := s.db.QueryContext(ctx,
|
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
|
FROM summary_messages sm
|
||||||
JOIN messages m ON m.message_id = sm.message_id
|
JOIN messages m ON m.message_id = sm.message_id
|
||||||
WHERE sm.summary_id = ?
|
WHERE sm.summary_id = ?
|
||||||
|
|
@ -555,6 +603,7 @@ func (s *Store) GetSummarySourceMessages(ctx context.Context, summaryID string)
|
||||||
&msg.ConversationID,
|
&msg.ConversationID,
|
||||||
&msg.Role,
|
&msg.Role,
|
||||||
&msg.Content,
|
&msg.Content,
|
||||||
|
&msg.ReasoningContent,
|
||||||
&msg.TokenCount,
|
&msg.TokenCount,
|
||||||
&createdAt,
|
&createdAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestStoreAddMessageWithParts(t *testing.T) {
|
||||||
s := openTestStore(t)
|
s := openTestStore(t)
|
||||||
ctx := context.Background()
|
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) {
|
func TestStoreGetMessageCount(t *testing.T) {
|
||||||
s := openTestStore(t)
|
s := openTestStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
@ -275,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 ---
|
// --- Summary Operations ---
|
||||||
|
|
||||||
func TestStoreCreateAndGetSummary(t *testing.T) {
|
func TestStoreCreateAndGetSummary(t *testing.T) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue