diff --git a/pkg/seahorse/short_compaction.go b/pkg/seahorse/short_compaction.go index 71f3032d2..360c6f7ab 100644 --- a/pkg/seahorse/short_compaction.go +++ b/pkg/seahorse/short_compaction.go @@ -3,6 +3,7 @@ package seahorse import ( "context" "fmt" + "sort" "time" "github.com/sipeed/picoclaw/pkg/logger" @@ -480,11 +481,15 @@ func (e *CompactionEngine) selectShallowestCondensationCandidate( } // Find shallowest depth with enough candidates - for depth := 0; ; depth++ { - group, ok := depthGroups[depth] - if !ok { - break - } + // Collect all depths and sort to handle non-consecutive depths + var depths []int + for depth := range depthGroups { + depths = append(depths, depth) + } + sort.Ints(depths) + + for _, depth := range depths { + group := depthGroups[depth] if len(group) >= minFanout { // Load summaries var result []Summary diff --git a/pkg/seahorse/short_engine_test.go b/pkg/seahorse/short_engine_test.go index d2f108efd..d64634fb7 100644 --- a/pkg/seahorse/short_engine_test.go +++ b/pkg/seahorse/short_engine_test.go @@ -8,6 +8,7 @@ import ( "strings" "sync" "testing" + "time" ) // helper: open a test engine with in-memory DB @@ -1373,3 +1374,75 @@ func TestAssemblerLazyInitRace(t *testing.T) { wg.Wait() } } + +// --- selectShallowestCondensationCandidate with non-consecutive depths --- + +func TestSelectShallowestCondensationWithNonConsecutiveDepths(t *testing.T) { + e := newTestEngineForConcurrency(t) + defer e.Close() + ctx := context.Background() + sessionKey := "test-non-consecutive-depths" + + // Create conversation + conv, err := e.store.GetOrCreateConversation(ctx, sessionKey) + if err != nil { + t.Fatalf("GetOrCreateConversation: %v", err) + } + + // Create summaries with non-consecutive depths: 0 and 1 have < 5, 2 is missing, 3 has >= 5 + // This tests the bug: when depth=2 is missing, the loop breaks and depth=3 is never checked + // Need > FreshTailCount(32) summaries so they are not all in fresh tail + // Depth 0: 3 summaries (not enough), Depth 1: 3 summaries (not enough) + // Depth 2: 0 summaries (missing), Depth 3: 40 summaries (enough) + depths := []int{0, 0, 0, 1, 1, 1} + for i := 0; i < 40; i++ { + depths = append(depths, 3) + } + now := time.Now().UTC() + + for i, depth := range depths { + sum, createErr := e.store.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: conv.ConversationID, + Kind: SummaryKindLeaf, + Depth: depth, + Content: fmt.Sprintf("summary depth %d #%d", depth, i), + TokenCount: 10, + EarliestAt: &now, + LatestAt: &now, + }) + if createErr != nil { + t.Fatalf("CreateSummary: %v", createErr) + } + // Add to context items (not in fresh tail) + if appendErr := e.store.AppendContextSummary(ctx, conv.ConversationID, sum.SummaryID); appendErr != nil { + t.Fatalf("AppendContextSummary: %v", appendErr) + } + } + + // Initialize compaction engine (lazy init) + e.initCompactionOnce() + + // Call selectShallowestCondensationCandidate + candidates, err := e.compaction.selectShallowestCondensationCandidate(ctx, conv.ConversationID, false) + if err != nil { + t.Fatalf("selectShallowestCondensationCandidate: %v", err) + } + + // Should find depth=0 (shallowest) with 5 summaries + if candidates == nil { + t.Fatal("expected candidates, got nil") + } + if len(candidates) < CondensedMinFanout { + t.Errorf("expected at least %d candidates, got %d", CondensedMinFanout, len(candidates)) + } + + // Verify all returned summaries have the same depth + if len(candidates) > 0 { + expectedDepth := candidates[0].Depth + for _, c := range candidates[1:] { + if c.Depth != expectedDepth { + t.Errorf("candidates have mixed depths: %d vs %d", expectedDepth, c.Depth) + } + } + } +}