diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 062224172..24ccb2954 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -77,3 +77,18 @@ jobs: - name: Print coverage summary if: always() run: go tool cover -func=coverage.txt | tail -1 + + integration-test: + runs-on: ubuntu-latest + needs: test + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Run integration tests + run: make test-integration diff --git a/Makefile b/Makefile index 58d58f27b..0f651b5c2 100644 --- a/Makefile +++ b/Makefile @@ -175,6 +175,12 @@ fantasy-sync: fantasy-patch: @./scripts/sync-fantasy.sh --save-patch $(NAME) +## test-integration: Run integration tests (requires build tags) +test-integration: + @echo "Running integration tests..." + @$(GO) test -tags integration -count=1 -timeout 120s -v ./pkg/memory/... + @echo "Integration tests OK" + ## check: Run vet, fmt, and verify dependencies check: deps fmt vet test diff --git a/pkg/memory/integration_test.go b/pkg/memory/integration_test.go new file mode 100644 index 000000000..f16b73415 --- /dev/null +++ b/pkg/memory/integration_test.go @@ -0,0 +1,268 @@ +//go:build integration + +package memory_test + +import ( + "context" + "testing" + + "github.com/sipeed/picoclaw/pkg/ids" + "github.com/sipeed/picoclaw/pkg/memory" + "github.com/sipeed/picoclaw/pkg/memory/delegate" + memstore "github.com/sipeed/picoclaw/pkg/memory/store" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + testAgent = "integration-agent" + testSession = "integration-sess" +) + +func setupFullStack(t *testing.T) (*memstore.MemoryStore, *delegate.LibSQLDelegate) { + t.Helper() + del, err := delegate.NewLibSQLInMemory() + require.NoError(t, err, "create in-memory delegate") + require.NoError(t, del.Init(context.Background()), "run goose migrations") + t.Cleanup(func() { del.Close() }) + + chunker := memstore.NewMarkdownChunker(memstore.DefaultMarkdownChunkerConfig()) + store := memstore.New(del, chunker, nil, memstore.DefaultConfig()) + return store, del +} + +func TestIntegration_GooseMigrationIdempotent(t *testing.T) { + del, err := delegate.NewLibSQLInMemory() + require.NoError(t, err) + defer del.Close() + + ctx := context.Background() + require.NoError(t, del.Init(ctx), "first migration up") + require.NoError(t, del.Init(ctx), "idempotent re-Init") +} + +func TestIntegration_BlobPK_RoundTrip(t *testing.T) { + store, _ := setupFullStack(t) + ctx := context.Background() + + item := &memory.RecallItem{ + AgentID: testAgent, + SessionKey: testSession, + Role: "user", + Sector: memory.SectorEpisodic, + Importance: 0.7, + Content: "BLOB PK integration test item", + } + + require.NoError(t, store.StoreRecall(ctx, item)) + assert.False(t, item.ID.IsZero(), "ID should be assigned after store") + + fetched, err := store.GetRecall(ctx, item.ID) + require.NoError(t, err) + require.NotNil(t, fetched) + assert.Equal(t, item.ID, fetched.ID, "BLOB PK round-trip must preserve ID") + assert.Equal(t, "BLOB PK integration test item", fetched.Content) +} + +func TestIntegration_ArchivalChunking_EndToEnd(t *testing.T) { + store, del := setupFullStack(t) + ctx := context.Background() + + longContent := "# Architecture Notes\n\nThe system uses hexagonal architecture with ports and adapters.\n\n" + longContent += "## Database Layer\n\nWe use libSQL with BLOB primary keys for storage efficiency.\n\n" + longContent += "## Memory System\n\nThree-tier MemGPT: working context, recall, archival.\n" + + recallID, err := store.StoreArchival(ctx, longContent, testAgent, map[string]string{ + "agent_id": testAgent, + "session_key": testSession, + }) + require.NoError(t, err) + assert.False(t, recallID.IsZero(), "StoreArchival should return a valid recall ID") + + chunks, err := del.ListArchivalChunks(ctx, recallID) + require.NoError(t, err) + assert.NotEmpty(t, chunks, "should create at least one archival chunk") + + for _, chunk := range chunks { + assert.False(t, chunk.ID.IsZero(), "chunk ID should not be zero") + assert.Equal(t, recallID, chunk.RecallID, "chunk must reference parent recall item") + } +} + +func TestIntegration_FTS5Search(t *testing.T) { + store, _ := setupFullStack(t) + ctx := context.Background() + + items := []struct { + content string + role string + }{ + {"The quick brown fox jumps over the lazy dog", "user"}, + {"Implementing vector search with F32_BLOB embeddings", "assistant"}, + {"Goose migrations handle schema versioning", "assistant"}, + {"The user prefers dark mode in their IDE", "user"}, + } + for _, it := range items { + ri := &memory.RecallItem{ + AgentID: testAgent, + SessionKey: testSession, + Role: it.role, + Sector: memory.SectorEpisodic, + Content: it.content, + } + require.NoError(t, store.StoreRecall(ctx, ri)) + } + + results, err := store.Search(ctx, "vector embeddings", memory.SearchOptions{ + AgentID: testAgent, + Limit: 10, + }) + require.NoError(t, err) + assert.NotEmpty(t, results, "FTS5 should return results for 'vector embeddings'") + + found := false + for _, r := range results { + if r.Content == "Implementing vector search with F32_BLOB embeddings" { + found = true + break + } + } + assert.True(t, found, "FTS5 should find the vector search item") +} + +func TestIntegration_CascadeDelete(t *testing.T) { + store, del := setupFullStack(t) + ctx := context.Background() + + recallID, err := store.StoreArchival(ctx, "Archival content to be cascade deleted", testAgent, map[string]string{ + "agent_id": testAgent, + "session_key": testSession, + }) + require.NoError(t, err) + + chunks, err := del.ListArchivalChunks(ctx, recallID) + require.NoError(t, err) + assert.NotEmpty(t, chunks) + + require.NoError(t, store.DeleteRecall(ctx, recallID)) + + for _, chunk := range chunks { + fetched, err := del.GetArchivalChunk(ctx, chunk.ID) + require.NoError(t, err) + assert.Nil(t, fetched, "archival chunks should be cascade-deleted with parent recall item") + } +} + +func TestIntegration_ToolOffload(t *testing.T) { + store, _ := setupFullStack(t) + ctx := context.Background() + + largeResult := "" + for i := 0; i < 500; i++ { + largeResult += "This is a line of tool output that contributes to the total size. " + } + + refID, summary, err := store.OffloadToolResult(ctx, "code_search", largeResult, testAgent, testSession) + require.NoError(t, err) + assert.False(t, refID.IsZero(), "offload should return a valid recall ID") + assert.NotEmpty(t, summary, "summary should be non-empty") + assert.Contains(t, summary, refID.String(), "summary should reference the recall ID") +} + +func TestIntegration_WorkingContext_Persistence(t *testing.T) { + _, del := setupFullStack(t) + ctx := context.Background() + + require.NoError(t, del.UpsertWorkingContext(ctx, testAgent, testSession, "initial state")) + + wc, err := del.GetWorkingContext(ctx, testAgent, testSession) + require.NoError(t, err) + require.NotNil(t, wc) + assert.Equal(t, "initial state", wc.Content) + + require.NoError(t, del.UpsertWorkingContext(ctx, testAgent, testSession, "updated state")) + wc, err = del.GetWorkingContext(ctx, testAgent, testSession) + require.NoError(t, err) + assert.Equal(t, "updated state", wc.Content) +} + +func TestIntegration_Summary_CRUD(t *testing.T) { + store, del := setupFullStack(t) + ctx := context.Background() + + summary := &memory.MemorySummary{ + AgentID: testAgent, + SessionKey: testSession, + Content: "Summarized conversation about testing", + FromMsgIdx: 0, + ToMsgIdx: 10, + } + require.NoError(t, store.StoreSummary(ctx, summary)) + assert.False(t, summary.ID.IsZero(), "summary should have ID assigned") + + fetched, err := del.ListSummaries(ctx, testAgent, testSession, 1) + require.NoError(t, err) + require.Len(t, fetched, 1) + assert.Equal(t, summary.ID, fetched[0].ID) + assert.Equal(t, "Summarized conversation about testing", fetched[0].Content) +} + +func TestIntegration_IDUniqueness_AcrossEntities(t *testing.T) { + store, _ := setupFullStack(t) + ctx := context.Background() + + seenIDs := make(map[ids.UUID]string) + + for i := 0; i < 5; i++ { + item := &memory.RecallItem{ + AgentID: testAgent, + SessionKey: testSession, + Role: "user", + Sector: memory.SectorEpisodic, + Content: "uniqueness test item", + } + require.NoError(t, store.StoreRecall(ctx, item)) + if prev, exists := seenIDs[item.ID]; exists { + t.Fatalf("duplicate ID %s: recall item collides with %s", item.ID, prev) + } + seenIDs[item.ID] = "recall" + } + + summary := &memory.MemorySummary{ + AgentID: testAgent, + SessionKey: testSession, + Content: "summary for uniqueness test", + } + require.NoError(t, store.StoreSummary(ctx, summary)) + if prev, exists := seenIDs[summary.ID]; exists { + t.Fatalf("summary ID %s collides with %s", summary.ID, prev) + } + seenIDs[summary.ID] = "summary" + + assert.Len(t, seenIDs, 6, "all 6 entities should have unique IDs") +} + +func TestIntegration_ContextPressure(t *testing.T) { + store, _ := setupFullStack(t) + ctx := context.Background() + + pressure, err := store.ContextUsage(ctx, testAgent, testSession) + require.NoError(t, err) + require.NotNil(t, pressure) + assert.Equal(t, 0, pressure.RecallItemCount) + + for i := 0; i < 3; i++ { + item := &memory.RecallItem{ + AgentID: testAgent, + SessionKey: testSession, + Role: "user", + Sector: memory.SectorEpisodic, + Content: "pressure test item with some content", + } + require.NoError(t, store.StoreRecall(ctx, item)) + } + + pressure, err = store.ContextUsage(ctx, testAgent, testSession) + require.NoError(t, err) + assert.Equal(t, 3, pressure.RecallItemCount) +}