fix(seahorse): correct 3 adapter bugs in context management
- TokenCount: use full message (Content+ToolCalls+Media) instead of Content-only - Empty Content: rebuild Content from tool_result Parts when stored empty - Duplicate summaries: summaries only in Summary field, not in History messages - Grep: fix SearchResult.Snippet→Content for summaries - Schema: fix FTS5 SQL uses VIRTUAL TABLE not TEMP TABLE - TestFTS5SQLConstants: verify FTS5 SQL syntax correctness - Test: fix flaky TestCompactLeaf
This commit is contained in:
parent
72afb3d3f9
commit
f77ddbeebf
11 changed files with 372 additions and 198 deletions
|
|
@ -4,7 +4,6 @@ import (
|
|||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
|
|
@ -112,44 +111,15 @@ func (m *seahorseContextManager) Assemble(ctx context.Context, req *AssembleRequ
|
|||
return nil, fmt.Errorf("seahorse assemble: %w", err)
|
||||
}
|
||||
|
||||
// Convert to provider messages
|
||||
history := seahorseToProviderMessages(result)
|
||||
|
||||
// Format ALL summaries as XML with metadata (depth, kind, etc.)
|
||||
// This allows the LLM to understand the hierarchical structure
|
||||
var summaryParts []string
|
||||
for _, sum := range result.Summaries {
|
||||
if sum.Content != "" {
|
||||
// Get parent IDs for condensed summaries
|
||||
parentIDs := getParentSummaryIDs(ctx, m.engine.GetRetrieval().Store(), sum.SummaryID)
|
||||
xml := seahorse.FormatSummaryXML(&sum, parentIDs)
|
||||
summaryParts = append(summaryParts, xml)
|
||||
}
|
||||
}
|
||||
var summary string
|
||||
if len(summaryParts) > 0 {
|
||||
summary = strings.Join(summaryParts, "\n\n")
|
||||
}
|
||||
|
||||
// Summary is already formatted as XML with system prompt addition by assembler
|
||||
return &AssembleResponse{
|
||||
History: history,
|
||||
Summary: summary,
|
||||
Summary: result.Summary,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// getParentSummaryIDs retrieves parent summary IDs for a summary.
|
||||
func getParentSummaryIDs(ctx context.Context, store *seahorse.Store, summaryID string) []string {
|
||||
parents, err := store.GetSummaryParents(ctx, summaryID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
ids := make([]string, len(parents))
|
||||
for i, p := range parents {
|
||||
ids[i] = p.SummaryID
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// Compact compresses conversation history via seahorse summarization.
|
||||
func (m *seahorseContextManager) Compact(ctx context.Context, req *CompactRequest) error {
|
||||
if req == nil {
|
||||
|
|
@ -212,7 +182,7 @@ func providerToSeahorseMessage(msg protocoltypes.Message) seahorse.Message {
|
|||
Role: msg.Role,
|
||||
Content: msg.Content,
|
||||
ReasoningContent: msg.ReasoningContent,
|
||||
TokenCount: tokenizer.EstimateMessageTokens(providers.Message{Content: msg.Content}),
|
||||
TokenCount: tokenizer.EstimateMessageTokens(msg),
|
||||
}
|
||||
|
||||
// Convert ToolCalls → MessageParts
|
||||
|
|
@ -249,8 +219,6 @@ func providerToSeahorseMessage(msg protocoltypes.Message) seahorse.Message {
|
|||
}
|
||||
|
||||
// seahorseToProviderMessages converts a seahorse.AssembleResult to []providers.Message.
|
||||
// NOTE: Summaries are already included in Messages as XML-formatted messages by the assembler.
|
||||
// We do NOT convert Summaries separately to avoid double injection.
|
||||
func seahorseToProviderMessages(result *seahorse.AssembleResult) []protocoltypes.Message {
|
||||
messages := make([]protocoltypes.Message, 0, len(result.Messages))
|
||||
|
||||
|
|
@ -276,6 +244,9 @@ func seahorseToProviderMessages(result *seahorse.AssembleResult) []protocoltypes
|
|||
}
|
||||
if part.Type == "tool_result" {
|
||||
pm.ToolCallID = part.ToolCallID
|
||||
if pm.Content == "" && part.Text != "" {
|
||||
pm.Content = part.Text
|
||||
}
|
||||
}
|
||||
if part.Type == "media" && part.MediaURI != "" {
|
||||
pm.Media = append(pm.Media, part.MediaURI)
|
||||
|
|
|
|||
|
|
@ -221,15 +221,8 @@ func TestSeahorseToProviderMessages(t *testing.T) {
|
|||
Content: "hello",
|
||||
TokenCount: 5,
|
||||
}
|
||||
summary := seahorse.Summary{
|
||||
SummaryID: "sum_test",
|
||||
Kind: seahorse.SummaryKindLeaf,
|
||||
Content: "test summary content",
|
||||
TokenCount: 50,
|
||||
}
|
||||
|
||||
result := seahorseToProviderMessages(&seahorse.AssembleResult{
|
||||
Summaries: []seahorse.Summary{summary}, // Should be ignored
|
||||
Messages: []seahorse.Message{summaryMsg, rawMsg},
|
||||
})
|
||||
|
||||
|
|
@ -516,7 +509,8 @@ func TestSeahorseCompactRetryUsesCompactUntilUnder(t *testing.T) {
|
|||
if result == nil {
|
||||
t.Fatal("expected non-nil assemble result")
|
||||
}
|
||||
_ = result.TokenCount // compaction attempted — no assertion on exact count since no LLM
|
||||
// Compaction attempted — no assertion on exact count since no LLM
|
||||
_ = result.Summary
|
||||
}
|
||||
|
||||
// TestSeahorseRealLoopNoDuplicateMessages tests the real-world scenario:
|
||||
|
|
@ -693,42 +687,182 @@ func TestSeahorseAssembleReturnsAllSummaries(t *testing.T) {
|
|||
t.Fatalf("engine.Assemble: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Seahorse returned %d summaries", len(result.Summaries))
|
||||
for i, sum := range result.Summaries {
|
||||
contentPreview := sum.Content
|
||||
if len(contentPreview) > 50 {
|
||||
contentPreview = contentPreview[:50] + "..."
|
||||
}
|
||||
t.Logf(" summary[%d]: id=%s depth=%d tokens=%d content=%s",
|
||||
i, sum.SummaryID, sum.Depth, sum.TokenCount, contentPreview)
|
||||
}
|
||||
t.Logf("Seahorse returned Summary with %d chars", len(result.Summary))
|
||||
|
||||
// The Summary field in AssembleResponse should contain ALL summaries as XML
|
||||
// with metadata (depth, kind) so the LLM can understand the hierarchy
|
||||
if len(result.Summaries) > 1 {
|
||||
// Check if Summary field contains all summaries
|
||||
if resp.Summary == "" {
|
||||
t.Error("Summary field is empty but there are multiple summaries")
|
||||
}
|
||||
|
||||
// Each summary should be wrapped in XML with depth and kind attributes
|
||||
for i, sum := range result.Summaries {
|
||||
// Should contain XML tag with metadata
|
||||
expectedAttr := fmt.Sprintf(`depth="%d"`, sum.Depth)
|
||||
if !strings.Contains(resp.Summary, expectedAttr) {
|
||||
t.Errorf("Summary[%d] missing depth attribute %q in XML", i, expectedAttr)
|
||||
}
|
||||
expectedKind := fmt.Sprintf(`kind="%s"`, sum.Kind)
|
||||
if !strings.Contains(resp.Summary, expectedKind) {
|
||||
t.Errorf("Summary[%d] missing kind attribute %q in XML", i, expectedKind)
|
||||
}
|
||||
// Content should be present
|
||||
if sum.Content != "" && len(sum.Content) > 20 {
|
||||
prefix := sum.Content[:20]
|
||||
if !strings.Contains(resp.Summary, prefix) {
|
||||
t.Errorf("Summary[%d] content (prefix %q) not found in Summary", i, prefix)
|
||||
// The Summary field should contain XML summaries with metadata (depth, kind)
|
||||
// The assembler generates this from the Summaries list
|
||||
if len(resp.Summary) > 0 {
|
||||
// Should contain XML tag
|
||||
if !strings.Contains(resp.Summary, "<summary") {
|
||||
t.Error("Summary field should contain <summary XML tags")
|
||||
}
|
||||
// Should contain depth attribute
|
||||
if !strings.Contains(resp.Summary, `depth="`) {
|
||||
t.Error("Summary field should contain depth attribute")
|
||||
}
|
||||
// Should contain kind attribute
|
||||
if !strings.Contains(resp.Summary, `kind="`) {
|
||||
t.Error("Summary field should contain kind attribute")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderToSeahorseMessageTokenCountIncludesAllFields(t *testing.T) {
|
||||
// Message with only Content
|
||||
msgContentOnly := protocoltypes.Message{
|
||||
Role: "assistant",
|
||||
Content: "This is a simple response with some text content.",
|
||||
}
|
||||
resultContentOnly := providerToSeahorseMessage(msgContentOnly)
|
||||
|
||||
// Message with Content + ToolCalls
|
||||
msgWithToolCalls := protocoltypes.Message{
|
||||
Role: "assistant",
|
||||
Content: "This is a simple response with some text content.",
|
||||
ToolCalls: []protocoltypes.ToolCall{
|
||||
{
|
||||
ID: "tc_123",
|
||||
Function: &protocoltypes.FunctionCall{
|
||||
Name: "read_file",
|
||||
Arguments: `{"path":"/home/user/document.txt"}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
resultWithToolCalls := providerToSeahorseMessage(msgWithToolCalls)
|
||||
|
||||
if resultWithToolCalls.TokenCount <= resultContentOnly.TokenCount {
|
||||
t.Errorf("TokenCount with ToolCalls = %d, should be > Content-only = %d",
|
||||
resultWithToolCalls.TokenCount, resultContentOnly.TokenCount)
|
||||
}
|
||||
|
||||
// Message with ToolCallID
|
||||
msgWithToolResult := protocoltypes.Message{
|
||||
Role: "tool",
|
||||
Content: "This is a simple response with some text content.",
|
||||
ToolCallID: "tc_456",
|
||||
}
|
||||
resultWithToolResult := providerToSeahorseMessage(msgWithToolResult)
|
||||
|
||||
if resultWithToolResult.TokenCount <= resultContentOnly.TokenCount {
|
||||
t.Errorf("TokenCount with ToolCallID = %d, should be > Content-only = %d",
|
||||
resultWithToolResult.TokenCount, resultContentOnly.TokenCount)
|
||||
}
|
||||
|
||||
// Message with Media
|
||||
msgWithMedia := protocoltypes.Message{
|
||||
Role: "user",
|
||||
Content: "This is a simple response with some text content.",
|
||||
Media: []string{"data:image/png;base64,abc123"},
|
||||
}
|
||||
resultWithMedia := providerToSeahorseMessage(msgWithMedia)
|
||||
|
||||
if resultWithMedia.TokenCount <= resultContentOnly.TokenCount {
|
||||
t.Errorf("TokenCount with Media = %d, should be > Content-only = %d",
|
||||
resultWithMedia.TokenCount, resultContentOnly.TokenCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeahorseToProviderMessagesRebuildsContentFromParts(t *testing.T) {
|
||||
msg := seahorse.Message{
|
||||
Role: "tool",
|
||||
Content: "",
|
||||
TokenCount: 50,
|
||||
Parts: []seahorse.MessagePart{
|
||||
{
|
||||
Type: "tool_result",
|
||||
ToolCallID: "tc_999",
|
||||
Text: "This is the actual tool output that should be in Content",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := seahorseToProviderMessages(&seahorse.AssembleResult{
|
||||
Messages: []seahorse.Message{msg},
|
||||
})
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 message, got %d", len(result))
|
||||
}
|
||||
|
||||
if result[0].Content == "" {
|
||||
t.Error("Content is empty - tool_result text was not rebuilt into Content")
|
||||
}
|
||||
if result[0].Content != "This is the actual tool output that should be in Content" {
|
||||
t.Errorf("Content = %q, want tool output text from Parts", result[0].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeahorseAssembleSummaryNotInMessages(t *testing.T) {
|
||||
engine, err := seahorse.NewEngine(seahorse.Config{
|
||||
DBPath: t.TempDir() + "/test.db",
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewEngine: %v", err)
|
||||
}
|
||||
defer engine.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
mgr := &seahorseContextManager{engine: engine}
|
||||
sessionKey := "test-no-dup-summary"
|
||||
|
||||
// Get the store to directly create a summary
|
||||
store := engine.GetRetrieval().Store()
|
||||
conv, err := store.GetOrCreateConversation(ctx, sessionKey)
|
||||
if err != nil {
|
||||
t.Fatalf("GetOrCreateConversation: %v", err)
|
||||
}
|
||||
|
||||
// Ingest some messages first
|
||||
for i := 0; i < 10; i++ {
|
||||
_ = mgr.Ingest(ctx, &IngestRequest{
|
||||
SessionKey: sessionKey,
|
||||
Message: protocoltypes.Message{Role: "user", Content: fmt.Sprintf("Message %d", i)},
|
||||
})
|
||||
}
|
||||
|
||||
// Create a summary
|
||||
input := seahorse.CreateSummaryInput{
|
||||
ConversationID: conv.ConversationID,
|
||||
Kind: seahorse.SummaryKindLeaf,
|
||||
Depth: 0,
|
||||
Content: "This is a test summary about the conversation",
|
||||
TokenCount: 50,
|
||||
}
|
||||
summary, err := store.CreateSummary(ctx, input)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSummary: %v", err)
|
||||
}
|
||||
err = store.AppendContextSummary(ctx, conv.ConversationID, summary.SummaryID)
|
||||
if err != nil {
|
||||
t.Fatalf("AppendContextSummary: %v", err)
|
||||
}
|
||||
|
||||
// Assemble
|
||||
resp, err := mgr.Assemble(ctx, &AssembleRequest{
|
||||
SessionKey: sessionKey,
|
||||
Budget: 50000,
|
||||
MaxTokens: 4096,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Assemble: %v", err)
|
||||
}
|
||||
|
||||
// Count how many times the summary content appears
|
||||
summaryContent := "This is a test summary"
|
||||
countInHistory := 0
|
||||
for _, msg := range resp.History {
|
||||
if strings.Contains(msg.Content, summaryContent) {
|
||||
countInHistory++
|
||||
}
|
||||
}
|
||||
|
||||
if countInHistory > 0 {
|
||||
t.Errorf("Summary content appears %d times in History - should be 0", countInHistory)
|
||||
}
|
||||
|
||||
// Summary should appear in Summary field
|
||||
if !strings.Contains(resp.Summary, summaryContent) {
|
||||
t.Error("Summary content should appear in response.Summary field")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,26 @@ import (
|
|||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
// SQL statements for FTS5 tables with trigram tokenizer.
|
||||
const (
|
||||
sqlCreateSummariesFTS = `CREATE VIRTUAL TABLE IF NOT EXISTS summaries_fts USING fts5(
|
||||
summary_id,
|
||||
content,
|
||||
tokenize="trigram"
|
||||
)`
|
||||
sqlCreateMessagesFTS = `CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
|
||||
message_id,
|
||||
content,
|
||||
tokenize="trigram"
|
||||
)`
|
||||
sqlCheckFTS5Available = `CREATE VIRTUAL TABLE IF NOT EXISTS _fts5_check USING fts5(content)`
|
||||
sqlCheckTrigramAvailable = `CREATE VIRTUAL TABLE IF NOT EXISTS _trigram_check USING fts5(content, tokenize="trigram")`
|
||||
sqlDropFTS5Check = `DROP TABLE IF EXISTS _fts5_check`
|
||||
sqlDropTrigramCheck = `DROP TABLE IF EXISTS _trigram_check`
|
||||
)
|
||||
|
||||
// runSchema creates or upgrades the database schema.
|
||||
// All migrations are idempotent (safe to run multiple times).
|
||||
// All schemas are idempotent (safe to run multiple times).
|
||||
func runSchema(db *sql.DB) error {
|
||||
// Check FTS5 support before creating tables
|
||||
if err := checkFTS5Support(db); err != nil {
|
||||
|
|
@ -86,18 +104,10 @@ func runSchema(db *sql.DB) error {
|
|||
)`,
|
||||
|
||||
// FTS5 virtual table with trigram tokenizer for CJK support
|
||||
`CREATE VIRTUAL TABLE IF NOT EXISTS summaries_fts USING fts5(
|
||||
summary_id,
|
||||
content,
|
||||
tokenize="trigram"
|
||||
)`,
|
||||
sqlCreateSummariesFTS,
|
||||
|
||||
// FTS5 virtual table for message search with trigram tokenizer
|
||||
`CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
|
||||
message_id,
|
||||
content,
|
||||
tokenize="trigram"
|
||||
)`,
|
||||
sqlCreateMessagesFTS,
|
||||
|
||||
// Indexes for common query patterns
|
||||
`CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id)`,
|
||||
|
|
@ -150,25 +160,25 @@ func checkFTS5Support(db *sql.DB) error {
|
|||
if err != nil {
|
||||
// sqlite_compileoption_used might not exist in older SQLite
|
||||
// Try a different approach: create a test FTS5 table
|
||||
_, testErr := db.Exec(`CREATE TEMP TABLE IF NOT EXISTS _fts5_test USING fts5(content)`)
|
||||
_, testErr := db.Exec(sqlCheckFTS5Available)
|
||||
if testErr != nil {
|
||||
return fmt.Errorf("SQLite FTS5 not available: %w (required for full-text search)", testErr)
|
||||
}
|
||||
db.Exec(`DROP TABLE IF EXISTS _fts5_test`)
|
||||
db.Exec(sqlDropFTS5Check)
|
||||
} else if fts5Enabled == 0 {
|
||||
return fmt.Errorf("SQLite was compiled without FTS5 support (required for full-text search)")
|
||||
}
|
||||
|
||||
// Check if trigram tokenizer is available by trying to create a test table
|
||||
// Not all SQLite builds include the trigram tokenizer
|
||||
_, err = db.Exec(`CREATE TEMP TABLE IF NOT EXISTS _trigram_test USING fts5(content, tokenize="trigram")`)
|
||||
_, err = db.Exec(sqlCheckTrigramAvailable)
|
||||
if err != nil {
|
||||
logger.WarnCF("seahorse", "SQLite trigram tokenizer not available, CJK search may be limited",
|
||||
map[string]any{"error": err.Error()})
|
||||
// Trigram is not strictly required, just better for CJK
|
||||
// Don't return error, just log warning
|
||||
} else {
|
||||
db.Exec(`DROP TABLE IF EXISTS _trigram_test`)
|
||||
db.Exec(sqlDropTrigramCheck)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -181,3 +181,31 @@ func TestMigrationSummaryParentsPK(t *testing.T) {
|
|||
t.Error("expected unique constraint violation for duplicate summary_parents link")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFTS5SQLConstants(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
// Verify FTS5 check SQL executes without error
|
||||
_, err := db.Exec(sqlCheckFTS5Available)
|
||||
if err != nil {
|
||||
t.Errorf("sqlCheckFTS5Available failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify trigram check SQL executes without error
|
||||
_, err = db.Exec(sqlCheckTrigramAvailable)
|
||||
if err != nil {
|
||||
t.Errorf("sqlCheckTrigramAvailable failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify summaries_fts SQL executes without error
|
||||
_, err = db.Exec(sqlCreateSummariesFTS)
|
||||
if err != nil {
|
||||
t.Errorf("sqlCreateSummariesFTS failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify messages_fts SQL executes without error
|
||||
_, err = db.Exec(sqlCreateMessagesFTS)
|
||||
if err != nil {
|
||||
t.Errorf("sqlCreateMessagesFTS failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -134,24 +134,6 @@ func (a *Assembler) Assemble(ctx context.Context, convID int64, input AssembleIn
|
|||
if r.summary.Kind == SummaryKindCondensed {
|
||||
condensedCount++
|
||||
}
|
||||
// Load parent IDs for XML formatting
|
||||
parentSummaries, err := a.store.GetSummaryParents(ctx, r.summary.SummaryID)
|
||||
if err != nil {
|
||||
logger.WarnCF("seahorse", "assemble: get summary parents", map[string]any{
|
||||
"summary_id": r.summary.SummaryID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
var parentIDs []string
|
||||
for _, ps := range parentSummaries {
|
||||
parentIDs = append(parentIDs, ps.SummaryID)
|
||||
}
|
||||
xmlMsg := FormatSummaryXML(r.summary, parentIDs)
|
||||
messages = append(messages, Message{
|
||||
Role: "system",
|
||||
Content: xmlMsg,
|
||||
TokenCount: r.tokenCount,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -168,12 +150,37 @@ func (a *Assembler) Assemble(ctx context.Context, convID int64, input AssembleIn
|
|||
}
|
||||
}
|
||||
|
||||
// Build Summary field: all XML summaries + system prompt addition
|
||||
var summaryParts []string
|
||||
for _, sum := range summaries {
|
||||
if sum.Content == "" {
|
||||
continue
|
||||
}
|
||||
// Load parent IDs for XML formatting
|
||||
parentSummaries, err := a.store.GetSummaryParents(ctx, sum.SummaryID)
|
||||
if err != nil {
|
||||
logger.WarnCF("seahorse", "assemble: get summary parents", map[string]any{
|
||||
"summary_id": sum.SummaryID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
var parentIDs []string
|
||||
for _, ps := range parentSummaries {
|
||||
parentIDs = append(parentIDs, ps.SummaryID)
|
||||
}
|
||||
summaryParts = append(summaryParts, FormatSummaryXML(&sum, parentIDs))
|
||||
}
|
||||
summary := strings.Join(summaryParts, "\n\n")
|
||||
if systemPromptAddition != "" {
|
||||
if summary != "" {
|
||||
summary += "\n\n"
|
||||
}
|
||||
summary += systemPromptAddition
|
||||
}
|
||||
|
||||
return &AssembleResult{
|
||||
Messages: messages,
|
||||
Summaries: summaries,
|
||||
TokenCount: totalTokens,
|
||||
SourceIDs: sourceIDs,
|
||||
SystemPromptAddition: systemPromptAddition,
|
||||
Summary: summary,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,11 +35,8 @@ func TestAssemblerAssembleEmpty(t *testing.T) {
|
|||
if len(result.Messages) != 0 {
|
||||
t.Errorf("Messages = %d, want 0", len(result.Messages))
|
||||
}
|
||||
if len(result.Summaries) != 0 {
|
||||
t.Errorf("Summaries = %d, want 0", len(result.Summaries))
|
||||
}
|
||||
if result.TokenCount != 0 {
|
||||
t.Errorf("TokenCount = %d, want 0", result.TokenCount)
|
||||
if result.Summary != "" {
|
||||
t.Errorf("Summary = %q, want empty", result.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -72,8 +69,9 @@ func TestAssemblerAssembleMessagesOnly(t *testing.T) {
|
|||
if result.Messages[1].Content != "world" {
|
||||
t.Errorf("Messages[1].Content = %q, want 'world'", result.Messages[1].Content)
|
||||
}
|
||||
if result.TokenCount != 10 {
|
||||
t.Errorf("TokenCount = %d, want 10", result.TokenCount)
|
||||
// No summaries, so Summary should be empty
|
||||
if result.Summary != "" {
|
||||
t.Errorf("Summary = %q, want empty", result.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -107,15 +105,19 @@ func TestAssemblerAssembleWithSummary(t *testing.T) {
|
|||
t.Fatalf("Assemble: %v", err)
|
||||
}
|
||||
|
||||
if len(result.Summaries) != 1 {
|
||||
t.Fatalf("Summaries = %d, want 1", len(result.Summaries))
|
||||
// Messages = 2 raw messages (summaries are in Summary field, not Messages)
|
||||
if len(result.Messages) != 2 {
|
||||
t.Errorf("Messages = %d, want 2 (raw messages only)", len(result.Messages))
|
||||
}
|
||||
if result.Summaries[0].SummaryID != summary.SummaryID {
|
||||
t.Errorf("Summary ID = %q, want %q", result.Summaries[0].SummaryID, summary.SummaryID)
|
||||
// Summary should contain XML with summary content
|
||||
if result.Summary == "" {
|
||||
t.Error("Summary should not be empty when summary exists")
|
||||
}
|
||||
// Messages = 1 summary-as-user-msg + 2 raw messages = 3
|
||||
if len(result.Messages) != 3 {
|
||||
t.Errorf("Messages = %d, want 3 (1 summary + 2 raw)", len(result.Messages))
|
||||
if !strings.Contains(result.Summary, summary.Content) {
|
||||
t.Errorf("Summary should contain summary content %q", summary.Content)
|
||||
}
|
||||
if !strings.Contains(result.Summary, "<summary") {
|
||||
t.Error("Summary should contain <summary XML tag")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -220,20 +222,19 @@ func TestAssemblerSummaryXMLFormat(t *testing.T) {
|
|||
t.Fatalf("Assemble: %v", err)
|
||||
}
|
||||
|
||||
// Summary should be formatted as XML system message
|
||||
if len(result.Messages) < 1 {
|
||||
t.Fatal("expected at least 1 message (summary formatted as message)")
|
||||
// Messages should only contain raw messages (no XML summary in Messages)
|
||||
if len(result.Messages) != 1 {
|
||||
t.Errorf("Messages = %d, want 1 (raw message only)", len(result.Messages))
|
||||
}
|
||||
summaryMsg := result.Messages[0]
|
||||
if summaryMsg.Role != "system" {
|
||||
t.Errorf("summary message role = %q, want 'system'", summaryMsg.Role)
|
||||
// Summary should contain XML with summary content
|
||||
if result.Summary == "" {
|
||||
t.Fatal("Summary should not be empty")
|
||||
}
|
||||
// Should contain XML summary tags
|
||||
if !contains(summaryMsg.Content, "<summary") {
|
||||
t.Errorf("summary message missing <summary tag: %q", summaryMsg.Content)
|
||||
if !contains(result.Summary, "<summary") {
|
||||
t.Errorf("Summary missing <summary tag: %q", result.Summary)
|
||||
}
|
||||
if !contains(summaryMsg.Content, summary.SummaryID) {
|
||||
t.Errorf("summary message missing summary ID: %q", summaryMsg.Content)
|
||||
if !contains(result.Summary, summary.SummaryID) {
|
||||
t.Errorf("Summary missing summary ID: %q", result.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -261,48 +262,21 @@ func TestAssemblerSummaryXMLEscaping(t *testing.T) {
|
|||
t.Fatalf("Assemble: %v", err)
|
||||
}
|
||||
|
||||
if len(result.Messages) < 1 {
|
||||
t.Fatal("expected at least 1 message")
|
||||
// Summary field should contain XML with escaped special characters
|
||||
if result.Summary == "" {
|
||||
t.Fatal("Summary should not be empty")
|
||||
}
|
||||
|
||||
// The XML should be well-formed - no unescaped special characters
|
||||
xmlContent := result.Messages[0].Content
|
||||
|
||||
// Check that special characters are escaped
|
||||
if strings.Contains(xmlContent, "<tags>") {
|
||||
t.Errorf("BUG: unescaped < in summary content: %q", xmlContent)
|
||||
if strings.Contains(result.Summary, "<tags>") {
|
||||
t.Errorf("BUG: unescaped < in summary content: %q", result.Summary)
|
||||
}
|
||||
if strings.Contains(xmlContent, `"hello"`) {
|
||||
t.Errorf("BUG: unescaped \" in summary content: %q", xmlContent)
|
||||
if strings.Contains(result.Summary, `"hello"`) {
|
||||
t.Errorf("BUG: unescaped \" in summary content: %q", result.Summary)
|
||||
}
|
||||
// & should be escaped as &
|
||||
if strings.Contains(xmlContent, " & ") {
|
||||
t.Errorf("BUG: unescaped & in summary content: %q", xmlContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssemblerSourceIDs(t *testing.T) {
|
||||
s, convID := setupAssemblerStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create messages
|
||||
msg1, _ := s.AddMessage(ctx, convID, "user", "hello", 5)
|
||||
msg2, _ := s.AddMessage(ctx, convID, "assistant", "world", 5)
|
||||
|
||||
s.UpsertContextItems(ctx, convID, []ContextItem{
|
||||
{Ordinal: 100, ItemType: "message", MessageID: msg1.ID, TokenCount: 5},
|
||||
{Ordinal: 200, ItemType: "message", MessageID: msg2.ID, TokenCount: 5},
|
||||
})
|
||||
|
||||
a := &Assembler{store: s, config: Config{}}
|
||||
result, err := a.Assemble(ctx, convID, AssembleInput{Budget: 100})
|
||||
if err != nil {
|
||||
t.Fatalf("Assemble: %v", err)
|
||||
}
|
||||
|
||||
// SourceIDs should contain message IDs
|
||||
if len(result.SourceIDs) != 2 {
|
||||
t.Errorf("SourceIDs = %d, want 2", len(result.SourceIDs))
|
||||
if strings.Contains(result.Summary, " & ") {
|
||||
t.Errorf("BUG: unescaped & in summary content: %q", result.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -340,11 +314,11 @@ func TestAssemblerSummaryXMLWithParents(t *testing.T) {
|
|||
t.Fatalf("Assemble: %v", err)
|
||||
}
|
||||
|
||||
// Find the summary message (first message, formatted as XML)
|
||||
if len(result.Messages) < 1 {
|
||||
t.Fatal("expected at least 1 message")
|
||||
// Summary field should contain XML with parent information
|
||||
if result.Summary == "" {
|
||||
t.Fatal("Summary should not be empty")
|
||||
}
|
||||
xmlContent := result.Messages[0].Content
|
||||
xmlContent := result.Summary
|
||||
|
||||
// Should contain <parents> section with parent ID
|
||||
if !contains(xmlContent, "<parents>") {
|
||||
|
|
@ -388,10 +362,10 @@ func TestAssemblerSummaryXMLIncludesDescendantCount(t *testing.T) {
|
|||
t.Fatalf("Assemble: %v", err)
|
||||
}
|
||||
|
||||
if len(result.Messages) < 1 {
|
||||
t.Fatal("expected at least 1 message")
|
||||
if result.Summary == "" {
|
||||
t.Fatal("Summary should not be empty")
|
||||
}
|
||||
xmlContent := result.Messages[0].Content
|
||||
xmlContent := result.Summary
|
||||
|
||||
// Should contain descendant_count="8"
|
||||
if !contains(xmlContent, `descendant_count="8"`) {
|
||||
|
|
@ -425,7 +399,10 @@ func TestAssemblerLeafSummaryNoParents(t *testing.T) {
|
|||
t.Fatalf("Assemble: %v", err)
|
||||
}
|
||||
|
||||
xmlContent := result.Messages[0].Content
|
||||
if result.Summary == "" {
|
||||
t.Fatal("Summary should not be empty")
|
||||
}
|
||||
xmlContent := result.Summary
|
||||
|
||||
// Leaf summary should NOT have <parents> section
|
||||
if contains(xmlContent, "<parents>") {
|
||||
|
|
@ -472,9 +449,13 @@ func TestAssemblerDepthAwarePrompt(t *testing.T) {
|
|||
t.Fatalf("Assemble: %v", err)
|
||||
}
|
||||
|
||||
// Should have a depth-aware prompt
|
||||
if result.SystemPromptAddition == "" {
|
||||
t.Error("expected non-empty SystemPromptAddition when depth >= 2")
|
||||
// Should have a depth-aware prompt in Summary field
|
||||
if result.Summary == "" {
|
||||
t.Error("expected non-empty Summary when depth >= 2")
|
||||
}
|
||||
// SystemPromptAddition is embedded in Summary field
|
||||
if !strings.Contains(result.Summary, "multi-level summarization") {
|
||||
t.Error("Summary should contain system prompt addition about multi-level summarization")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -643,6 +643,12 @@ func (e *CompactionEngine) generateCondensedSummary(ctx context.Context, summari
|
|||
func (e *CompactionEngine) runCondensedLoop(ctx context.Context, convID int64) {
|
||||
var prevTokens int
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
tokensBefore, err := e.store.GetContextTokenCount(ctx, convID)
|
||||
if err != nil {
|
||||
logger.ErrorCF("seahorse", "condensed: get tokens", map[string]any{"error": err.Error()})
|
||||
|
|
|
|||
|
|
@ -28,7 +28,11 @@ func waitForCondensed(ce *CompactionEngine, convID int64, timeout time.Duration)
|
|||
|
||||
func newTestCompactionEngine(t *testing.T) (*CompactionEngine, *Store, int64) {
|
||||
t.Helper()
|
||||
s := openTestStore(t)
|
||||
db := openTestDB(t)
|
||||
if err := runSchema(db); err != nil {
|
||||
t.Fatalf("migration: %v", err)
|
||||
}
|
||||
s := &Store{db: db}
|
||||
ctx := context.Background()
|
||||
conv, _ := s.GetOrCreateConversation(ctx, "test:compact")
|
||||
shutdownCtx, shutdownCancel := context.WithCancel(context.Background())
|
||||
|
|
@ -40,7 +44,8 @@ func newTestCompactionEngine(t *testing.T) (*CompactionEngine, *Store, int64) {
|
|||
shutdownCancel: shutdownCancel,
|
||||
}
|
||||
convID := conv.ConversationID
|
||||
// Ensure async goroutines are stopped before database is closed
|
||||
// Ensure async goroutines are stopped before database is closed.
|
||||
// Register cleanup here (after openTestDB) so it runs BEFORE openTestDB's db.Close().
|
||||
t.Cleanup(func() {
|
||||
shutdownCancel()
|
||||
// Wait for async condensed goroutine to finish (poll condensing map)
|
||||
|
|
@ -852,7 +857,7 @@ func TestCompactAsyncDedup(t *testing.T) {
|
|||
ce, cancel := newTestCompactionEngineWithStore(s, slowComplete)
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
waitForCondensed(ce, convID, 2*time.Second)
|
||||
})
|
||||
|
||||
// Create conditions for condensed compaction
|
||||
|
|
|
|||
|
|
@ -47,10 +47,7 @@ type AssembleInput struct {
|
|||
// AssembleResult contains assembled context.
|
||||
type AssembleResult struct {
|
||||
Messages []Message `json:"messages"`
|
||||
Summaries []Summary `json:"summaries"`
|
||||
TokenCount int `json:"tokenCount"`
|
||||
SourceIDs []string `json:"sourceIds"`
|
||||
SystemPromptAddition string `json:"systemPromptAddition,omitempty"`
|
||||
Summary string `json:"summary"` // formatted XML summaries + system prompt addition
|
||||
}
|
||||
|
||||
const numSessionShards = 256
|
||||
|
|
|
|||
|
|
@ -1131,13 +1131,13 @@ func (s *Store) scanSearchResults(rows *sql.Rows, withRank bool) ([]SearchResult
|
|||
var kind string
|
||||
if withRank {
|
||||
// FTS5 mode: no TotalCount in query (set by caller after COUNT)
|
||||
if err := rows.Scan(&r.SummaryID, &r.ConversationID, &kind, &r.Snippet, &createdAt, &r.Rank); err != nil {
|
||||
if err := rows.Scan(&r.SummaryID, &r.ConversationID, &kind, &r.Content, &createdAt, &r.Rank); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
// LIKE mode: TotalCount from window function
|
||||
if err := rows.Scan(&r.SummaryID, &r.ConversationID, &kind,
|
||||
&r.Snippet, &createdAt, &r.TotalCount); err != nil {
|
||||
&r.Content, &createdAt, &r.TotalCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1106,3 +1106,38 @@ func TestSearchMessagesWithTimeFilter(t *testing.T) {
|
|||
t.Errorf("Since=1h-future: expected 0 results, got %d", len(results))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreSearchSummariesReturnsContent(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
conv, _ := s.GetOrCreateConversation(ctx, "agent:test")
|
||||
|
||||
// Create a summary with known content
|
||||
s.CreateSummary(ctx, CreateSummaryInput{
|
||||
ConversationID: conv.ConversationID,
|
||||
Kind: SummaryKindLeaf,
|
||||
Depth: 0,
|
||||
Content: "This is the summary content for testing",
|
||||
TokenCount: 10,
|
||||
})
|
||||
|
||||
// Search should return the full content, not empty
|
||||
results, err := s.SearchSummaries(ctx, SearchInput{
|
||||
Pattern: "summary content",
|
||||
Mode: "like",
|
||||
ConversationID: conv.ConversationID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SearchSummaries: %v", err)
|
||||
}
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("expected 1 result, got %d", len(results))
|
||||
}
|
||||
if results[0].Content == "" {
|
||||
t.Error("SearchResult.Content is empty, want full summary content")
|
||||
}
|
||||
if results[0].Content != "This is the summary content for testing" {
|
||||
t.Errorf("SearchResult.Content = %q, want %q", results[0].Content, "This is the summary content for testing")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue